diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 52b044de..5c196009 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,10 +1,9 @@ -FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-16 +FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04 -# 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"] +USER vscode + +# Install Volta +ARG HOME="/home/vscode" +ENV VOLTA_HOME="${HOME}/.volta" +ENV PATH="${VOLTA_HOME}/bin:${PATH}" +RUN bash -c "$(curl -fsSL https://get.volta.sh)" -- --skip-setup diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 75ff5b53..9f34149b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,8 +2,15 @@ { "dockerComposeFile": "../docker-compose.yml", "service": "dev", + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "moby": false, + "dockerDashComposeVersion": "v2" + } + }, "workspaceFolder": "${localWorkspaceFolder}", "shutdownAction": "stopCompose", + "forwardPorts": [10001, "hass:8123", "frigate:5000"], "portsAttributes": { "10001": { "label": "Rollup", @@ -18,33 +25,36 @@ "onAutoForward": "silent" } }, - "forwardPorts": [10001, "hass:8123", "frigate:5000"], "initializeCommand": ".devcontainer/initialize.sh", "postCreateCommand": "yarn install", - "extensions": [ - "github.vscode-pull-request-github", - "eamodio.gitlens", - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "bierner.lit-html", - "runem.lit-plugin", - "davidanson.vscode-markdownlint", - "redhat.vscode-yaml", - "lokalise.i18n-ally", - "ms-azuretools.vscode-docker" - ], - "settings": { - "files.eol": "\n", - "editor.tabSize": 2, - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true, - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "customizations": { + "vscode": { + "extensions": [ + "github.vscode-pull-request-github", + "eamodio.gitlens", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "bierner.lit-html", + "runem.lit-plugin", + "davidanson.vscode-markdownlint", + "redhat.vscode-yaml", + "lokalise.i18n-ally", + "ms-azuretools.vscode-docker" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 2, + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": 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 index 16bd40d6..f54791ce 160000 --- a/.devcontainer/frigate-hass-integration +++ b/.devcontainer/frigate-hass-integration @@ -1 +1 @@ -Subproject commit 16bd40d6226150265edd3019f85a23e8e9e2ba01 +Subproject commit f54791cef3c214eb8df4fd433beddc2b9e0a66a9 diff --git a/.devcontainer/preconfig/.storage/lovelace b/.devcontainer/preconfig/.storage/lovelace index 604e5a64..bda880d3 100644 --- a/.devcontainer/preconfig/.storage/lovelace +++ b/.devcontainer/preconfig/.storage/lovelace @@ -18,85 +18,17 @@ "cameras": [ { "camera_entity": "camera.big_buck_bunny", - "live_provider": "frigate-jsmpeg", + "live_provider": "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/.github/workflows/build.yml b/.github/workflows/build.yml index b2ec5442..2b83ca9c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,11 +16,20 @@ jobs: name: Test build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Node and Yarn + uses: volta-cli/action@v3 + + - name: Install dependencies + run: yarn install --immutable + - name: Build - run: | - yarn install - yarn run build + run: yarn run build + + - name: Test + run: yarn run test - name: HACS build validation uses: "hacs/action@21.12.1" @@ -35,4 +44,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: frigate-hass-card - path: dist/frigate-hass-card.js + path: dist/*.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e33c5f16..9fd76e24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,32 +4,52 @@ name: Release on: release: types: [published] + workflow_dispatch: jobs: release: name: Prepare release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v3 - # Build - - name: Build the file - run: | - yarn install - yarn run build + - name: Setup Node and Yarn + uses: volta-cli/action@v3 - # Upload build file to the releas as an asset. - - name: Upload zip to release + - name: Install dependencies + run: yarn install --immutable + + - name: Build the files + run: yarn run build + + - name: Zip the files + uses: thedoctor0/zip-release@0.7.1 + with: + type: zip + path: dist + filename: frigate-hass-card.zip + + - name: Upload JS files to release uses: svenstaro/upload-release-action@2.5.0 with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: dist/frigate-hass-card.js - asset_name: frigate-hass-card.js + file: dist/*.js + file_glob: true + tag: ${{ github.ref }} + overwrite: true + + - name: Upload Zip file to release + uses: svenstaro/upload-release-action@2.5.0 + + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: frigate-hass-card.zip tag: ${{ github.ref }} overwrite: true - name: HACS release validation - uses: "hacs/action@21.12.1" + uses: hacs/action@21.12.1 with: - category: "plugin" + category: plugin diff --git a/.gitignore b/.gitignore index 13f0c0f6..13e1d646 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,15 @@ package-lock.json .env .envrc + +stats.html +/coverage/ + +# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/.vscode/extensions.json b/.vscode/extensions.json index cd04d570..317f0be3 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -8,6 +8,7 @@ "runem.lit-plugin", "davidanson.vscode-markdownlint", "redhat.vscode-yaml", - "lokalise.i18n-ally" + "lokalise.i18n-ally", + "zixuanchen.vitest-explorer" ] } diff --git a/.vscode/i18n-ally-reviews.yml b/.vscode/i18n-ally-reviews.yml index 3daa9fde..011ea0f3 100644 --- a/.vscode/i18n-ally-reviews.yml +++ b/.vscode/i18n-ally-reviews.yml @@ -1,20 +1,6 @@ # 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: @@ -22,13 +8,6 @@ reviews: 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: @@ -36,11 +15,6 @@ reviews: 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: @@ -48,11 +22,6 @@ reviews: 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: @@ -60,11 +29,6 @@ reviews: 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: @@ -72,11 +36,6 @@ reviews: 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: @@ -84,11 +43,6 @@ reviews: 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: @@ -96,11 +50,6 @@ reviews: 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: @@ -108,11 +57,6 @@ reviews: 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: @@ -120,8 +64,3 @@ reviews: 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/settings.json b/.vscode/settings.json index a4a10938..31c57af5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "i18n-ally.sortKeys": true, "i18n-ally.keepFulfilled": true, "i18n-ally.editor.preferEditor": true, - "i18n-ally.translate.saveAsCandidates": true + "i18n-ally.translate.saveAsCandidates": true, + "vitest.commandLine": "npx vitest --root ." } diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/README.md b/README.md index 08298030..57c99b72 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ A full-featured Frigate Lovelace card: See more [screenshots](#screenshots) below. +## Supported Browsers + +Modern Safari, Firefox and Chrome-based browsers are supported, as well as the Home Assistant App on Android and iOS. Other/older browsers may work, but are unsupported. + ## Installation * [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. @@ -79,10 +83,23 @@ lovelace: type: module ``` +### Advanced Users: Installing Unreleased Versions + +You can install any unreleased version of the card by leveraging the GitHub Actions artifacts that are generated on every revision. Here is a video walkthrough installing the latest revision of the `release-4.1.0` branch: + +
+ Click here to show + +https://user-images.githubusercontent.com/29582865/228320074-6a2607f5-c637-48d5-b833-a553f8df8f4f.mp4 + +
+ ## Configuration At least 1 camera must be configured in the `cameras` section, but otherwise all configuration parameters are optional. + + ### Camera Options The `cameras` block configures a list of cameras the card should support. The first listed camera is the default. Camera configuration is under: @@ -99,28 +116,51 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) | 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. | -| `live_provider` | `auto` | :heavy_multiplication_x: | The choice of live stream provider. See [Live Providers](#live-providers) below.| -| `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. | +| `camera_entity` | | :white_check_mark: | 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. | +| `live_provider` | `auto` | :white_check_mark: | The choice of live stream provider. See [Live Providers](#live-providers) below.| +| `title` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | A friendly name for this camera to use in the card. | +| `icon` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | +| `hide` | `false` | :white_check_mark: | Whether or not to hide this as an independent camera (e.g. hidden on the live carousel, media filter, camera menu, and triggers cannot trigger this camera). This may be useful if this camera is exclusively used as a dependency of another camera. | +| `id` | `camera_entity`, `webrtc_card.entity` or `frigate.camera_name` if set (in that preference order). | :white_check_mark: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | +| `engine` | `auto` | :white_check_mark: | Which camera engine to use for this camera. If `auto` the card will attempt to choose the correct engine from the specified options. See [engines](#engines) below for valid options.| +| `frigate` | | :white_check_mark: | Options for a Frigate camera. See [Frigate configuration](#camera-frigate-configuration) below. | +| `dependencies` | | :white_check_mark: | Other cameras that this camera should depend upon. See [camera dependencies](#camera-dependencies-configuration) below. | +| `triggers` | | :white_check_mark: | Define what should cause this camera to update/trigger. See [camera triggers](#camera-trigger-configuration) below. | +| `webrtc_card` | | :white_check_mark: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | #### Available Live Providers -|Live Provider|Latency|Frame Rate|Installation|Description| -| -- | -- | -- | -- | -- | -|`ha` (default HA configuration)|Poor|High|Builtin|Use the built-in Home Assistant camera stream. The camera doesn't even need to be a Frigate camera! | -|`ha` (when configured with LL-HLS)|Better|High|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use an [LL-HLS](https://www.home-assistant.io/integrations/stream/#ll-hls) feed for lower latency.| -|`ha` (Native WebRTC)|Best|High|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use [native WebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) offering a very low-latency feed direct to your browser.| -|`frigate-jsmpeg`|Better|Low|Builtin|Stream the JSMPEG stream from Frigate (proxied via the Frigate integration). See [note below on the required integration version](#jsmpeg-troubleshooting) for this live provider to function. This is the only live provider that can view the Frigate `birdseye` view.| -|`webrtc-card`|Best|High|Separate installation required|Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc). Not to be confused with native Home Assistant WebRTC (use `ha` provider above).| +|Live Provider|Latency|Frame Rate|Loading Time|Installation|Description| +| -- | -- | -- | -- | -- | -- | +|`ha` (default HA configuration)|Poor|High|Better|Builtin|Use the built-in Home Assistant camera stream. The camera doesn't even need to be a Frigate camera! | +|`ha` (when configured with LL-HLS)|Better|High|Better|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use an [LL-HLS](https://www.home-assistant.io/integrations/stream/#ll-hls) feed for lower latency.| +|`ha` (Native WebRTC)|Best|High|Better|Builtin|Use the built-in Home Assistant camera streams -- can be configured to use [native WebRTC](https://www.home-assistant.io/integrations/rtsp_to_webrtc/) offering a very low-latency feed direct to your browser.| +|`image`|Poor|Poor|Best|Builtin|Use refreshing snapshots of the built-in Home Assistant camera streams.| +|`jsmpeg`|Better|Low|Poor|Builtin|Use a the JSMPEG stream.| +|`go2rtc`|Best|High|Better|Builtin|Uses [go2rtc](https://github.com/AlexxIT/go2rtc) to stream live feeds. This is supported by Frigate >= `0.12`.| +|`webrtc-card`|Best|High|Better|Separate installation required|Embed's [AlexxIT's WebRTC Card](https://github.com/AlexxIT/WebRTC) to stream live feed, requires manual extra setup, see [below](#webrtc). Not to be confused with native Home Assistant WebRTC (use `ha` provider above).| + + +#### Available Camera Engines + +##### Engine Capabilities + +|Engine|Live|Supports clips|Supports Snapshots|Supports Recordings|Supports Timeline|Favorite events|Favorite recordings| +| - | - | - | - | - | - | - | - | +|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | +|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | +|`motioneye`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | + +##### Live providers supported per Engine + +|Engine / Live Provider|`ha`|`image`|`jsmpeg`|`go2rtc`|`webrtc-card`| +| - | - | - | - | - | - | +|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +|`generic`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: | +|`motioneye`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | @@ -135,15 +175,67 @@ cameras: | 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_name` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view.| +| `url` | | :white_check_mark: | The URL of the frigate server. If set, this value will be (exclusively) used for a `Camera UI` menu button. All other communication with Frigate goes via Home Assistant. | +| `labels` | | :white_check_mark: | An array of Frigate labels used to filter events (clips & snapshots), e.g. [`person`, `car`].| +| `zones` | | :white_check_mark: | An array of Frigates zones used to filter events (clips & snapshots), e.g. [`front_door`, `front_steps`].| +| `client_id` | `frigate` | :white_check_mark: | 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 +#### Camera MotionEye 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. +The `motioneye` block configures options for a MotionEye camera. This configuration is included as part of a camera entry in the `cameras` array. + +```yaml +cameras: + - motioneye: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `url` | | :white_check_mark: | The URL of the MotionEye server. If set, this value will be (exclusively) used for a `Camera UI` menu button. | +| `images` | | :white_check_mark: | Configure how MotionEye images are consumed. See below. | +| `movies` | | :white_check_mark: | Configure how MotionEye movies are consumed. See below. | + +#### Camera MotionEye images and movies configuration + +The `images` and `movies` block configures options for a MotionEye camera. All +options for `images` and `movies` are under their respective blocks. The options +for both are the same. + +```yaml +cameras: + - motioneye: + images: +``` + +```yaml +cameras: + - motioneye: + movies: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `directory_pattern` | `%Y-%m-%d` | :white_check_mark: | The directory that motionEye is configured to store media into. May contain multiple sub-directories separated by `/`. Path must encode the date of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. | +| `file_pattern` | `%H-%M-%S` | :white_check_mark: | Within a directory (as matched by `directory_pattern`) the media items must exist and match this pattern. `file_pattern` must encode the time of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. | + +#### Live Provider: Camera go2rtc configuration + +The `go2rtc` block configures use of the `go2rtc` live provider. This configuration is included as part of a camera entry in the `cameras` array. + +```yaml +cameras: + - go2rtc: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `modes` | `[webrtc, mse, mp4, mjpeg]` | :white_check_mark: | An ordered array of `go2rtc` modes to use. Valid values are `webrtc`, `mse`, `mp4` or `mjpeg` values. | +| `stream` | Determind by camera engine (e.g. `frigate` camera name). | :white_check_mark: | A valid `go2rtc` stream name. | + +#### Live Provider: Camera WebRTC Card configuration + +Configures the `webrtc_card` live provider: ```yaml cameras: @@ -152,16 +244,47 @@ cameras: | Option | Default | Overridable | Description | | - | - | - | - | -| `entity` | | :heavy_multiplication_x: | The RTSP entity to pass to the WebRTC Card for this camera. Specify this OR `url` (below). | -| `url` | | :heavy_multiplication_x: | The RTSP url to pass to the WebRTC Card. Specify this OR `entity` (above). | +| `entity` | | :white_check_mark: | The RTSP entity to pass to the WebRTC Card for this camera. | +| `url` | Depends on the camera engine (e.g. Frigate will use the camera name by default since this is the [recommended setup](https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/))| :white_check_mark: | The RTSP url to pass to the WebRTC Card. | +| `*`| | :white_check_mark: | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides. | + See [Using the WebRTC Card](#webrtc) below for more details on how to use the WebRTC Card live provider. +#### Live Provider: Image Configuration + +All configuration is under: + +```yaml +cameras: + - image: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `refresh_seconds` | 1 | :white_check_mark: | The image will be refreshed at least every `refresh_seconds`. `0` implies no refreshing. | +| `url` | | :white_check_mark: | **Advanced**: A static image URL to be fetched in lieu of the Home Assistant image for the given camera. This may be useful for advanced configurations where the camera image is being provided by some non-Home Assistant system. This will also set the temporary loading image used when `show_image_during_load` is set to true under the `live` configuration. | + +#### Live Provider: JSMPEG Configuration + +All configuration is under: + +```yaml +cameras: + - jsmpeg: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `options` | | :white_check_mark: | **Advanced users only**: Control the underlying [JSMPEG library options](https://github.com/phoboslab/jsmpeg#usage). Supports setting these JSMPEG options `{audio, video, pauseWhenHidden, disableGl, disableWebAssembly, preserveDrawingBuffer, progressive, throttled, chunkSize, maxAudioLag, videoBufferSize, audioBufferSize}`. This is not necessary for the vast majority of users: only set these flags if you know what you're doing, as you may entirely break video rendering in the card.| + + + #### 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: +The `dependencies` block configures other cameras as dependents of this camera. Dependent cameras have their media fetched and merged with this camera by default, and offer their respective live views as 'substreams' of the main (depended upon) camera. Configuration is under: ```yaml cameras: @@ -170,8 +293,8 @@ cameras: | 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.| +| `cameras` | | :white_check_mark: | An optional array of other camera identifiers (see [camera IDs](#camera-ids)). If specified the card will fetch media for this camera and *also* recursively for the named cameras by default. Live views for the involved cameras will be available as 'substreams' of the main (depended upon) camera. 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 show multiple related live views, 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` | :white_check_mark: | Shortcut to specify all other cameras as dependent cameras.| @@ -186,13 +309,13 @@ cameras: | 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.| +| `motion` | `false` | :white_check_mark: | 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` | :white_check_mark: | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used.| +| `entities` | | :white_check_mark: | 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 +#### Camera IDs: Referring 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 `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. | @@ -200,6 +323,20 @@ Each camera configured in the card has a single identifier (`id`). For a given c See [the basic cameras configuration example](#basic-cameras-configuration) below. + + +### Camera Global Options + +**Advanced:** The optional `cameras_global` block configures global options that +apply to all cameras from the `cameras` section. For large configs, this can +avoid significant repetition across cameras. The configuration is under: + +```yaml +cameras_global: +``` + +The configuration options are identical to a single [camera entry](#camera-options). + ### View Options All configuration is under: @@ -262,7 +399,7 @@ See the [fully expanded menu configuration example](#config-expanded-menu) for h | Option | Default | Overridable | Description | | - | - | - | - | -| `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.| +| `style` | `hidden` | :white_check_mark: | The menu style to show by default, one of `none`, `hidden`, `hover`, `hover-card`, `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`.| @@ -287,10 +424,12 @@ menu: | `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`.| +| `recordings` | :white_check_mark: | The `recordings` view menu button: brings the user to the `recordings` view on tap and the most-recent `recording` 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.| +| `camera_ui` | :white_check_mark: | The `camera_ui` menu button: brings the user to a context-appropriate page on the UI of their camera engine (e.g. the Frigate camera homepage). Will only appear if the camera engine supports a camera UI (e.g. if `frigate.url` option is set for `frigate` engine users).| | `fullscreen` | :white_check_mark: | The `fullscreen` menu button: expand the card to consume the fullscreen. | +| `expand` | :white_check_mark: | The `expand` menu button: expand the card into a popup/dialog. | | `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. | @@ -318,49 +457,17 @@ See the [fully expanded live configuration example](#config-expanded-live) for h | `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_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_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). Note that if `auto_play` is enabled, the stream may mute itself automatically in order to honor the `auto_play` setting, as some browsers will not auto play media that is unmuted -- that is to say, where necessary, the `auto_play` parameter will take priority over the `auto_mute` parameter.| | `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` | `never` | :heavy_multiplication_x: | When to lazily **un**load lazyily-loaded cameras. `never` will never lazily-unload, `unselected` will lazy-unload a camera when it is unselected in the carousel, `hidden` will lazy-unload all cameras when the browser/tab becomes hidden or `all` on any opportunity to lazily unload (i.e. either case). This will cause a reloading delay on revisiting that camera in the carousel but will save the streaming network resources that are otherwise consumed. This option has no effect if `lazy_load` is false. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting.| | `draggable` | `true` | :heavy_multiplication_x: | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. | | `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. | -| `show_image_during_load` | `true` | :white_check_mark: | If `true`, during the initial stream load, a still image will be shown instead of the loading video stream. This still image will auto-refresh every 1 second and will be replaced with the live stream once loaded. | +| `show_image_during_load` | `true` | :white_check_mark: | If `true`, during the initial stream load, the `image` live provider will be shown instead of the loading video stream. This still image will auto-refresh and is replaced with the live stream once loaded. | | `actions` | | :white_check_mark: | Actions to use for the `live` view. See [actions](#actions) below.| | `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. | -| `jsmpeg` | | :white_check_mark: | Configuration for the `frigate-jsmpeg` live provider. See below.| -| `webrtc_card` | | :white_check_mark: | Configuration for the `webrtc-card` live provider. See below.| | `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| -#### Live Provider: JSMPEG Configuration - -All configuration is under: - -```yaml -live: - jsmpeg: -``` - -| Option | Default | Overridable | Description | -| - | - | - | - | -| `options` | | :white_check_mark: | **Advanced users only**: Control the underlying [JSMPEG library options](https://github.com/phoboslab/jsmpeg#usage). Supports setting these JSMPEG options `{audio, video, pauseWhenHidden, disableGl, disableWebAssembly, preserveDrawingBuffer, progressive, throttled, chunkSize, maxAudioLag, videoBufferSize, audioBufferSize}`. This is not necessary for the vast majority of users: only set these flags if you know what you're doing, as you may entirely break video rendering in the card.| - - - -#### Live Provider: WebRTC Card Configuration - -All configuration is under: - -```yaml -live: - webrtc_card: -``` - -| Option | Default | Overridable | Description | -| - | - | - | - | -| `*`| | :white_check_mark: | Any options specified in the `webrtc_card:` YAML dictionary are silently passed through to the AlexxIT's WebRTC Card. See [WebRTC Configuration](https://github.com/AlexxIT/WebRTC#configuration) for full details this external card provides. This implies that if `entity` or `url` are specified here they will override the matching named parameters under the per camera configuration. | - -See [Using WebRTC Card](#webrtc) below for more details on how to embed AlexxIT's WebRTC Card with the Frigate Card. - #### Live Controls: Thumbnails All configuration is under: @@ -376,9 +483,10 @@ live: | `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_download_control` | `true` | :white_check_mark: | Whether to show the download control on each 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.| +| `media` | `all` | :white_check_mark: | Whether to show `clips`, `snapshots` or `all` in the thumbnail carousel in the `live` view.| #### Live Controls: Next / Previous @@ -395,6 +503,26 @@ live: | `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` | 48 | :white_check_mark: | The size of the next/previous controls in pixels. Must be >= `20`. | +#### Live Controls: Mini Timeline + +All configuration is under: + +```yaml +live: + controls: + timeline: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `style` | `ribbon` | :white_check_mark: | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold` (below). | +| `window_seconds` | `3600` | :white_check_mark: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. | +| `clustering_threshold` | `3` | :white_check_mark: | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. | +| `media` | `all` | :white_check_mark: | 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` | :white_check_mark: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).| + +**Caution**: 🚩 For optimal UX, keep the settings for the mini-timeline in the `live` and `media_viewer` identical. Dragging the timeline may cause the card to change between the `live` view and `media_viewer` based views as the user pans between the past and present -- if the settings are different the timeline must "reset". + #### Live Controls: Title @@ -432,6 +560,7 @@ See the [fully expanded Media viewer configuration example](#config-expanded-med | `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. | +| `snapshot_click_plays_clip` | `true` | :heavy_multiplication_x: | Whether clicking on a snapshot in the media viewer should play a related clip. | | `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 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.| @@ -467,9 +596,30 @@ media_viewer: | `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_download_control` | `true` | :heavy_multiplication_x: | Whether to show the download control on each 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.| +#### Media Viewer Controls: Mini Timeline + +All configuration is under: + +```yaml +media_viewer: + controls: + timeline: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `style` | `ribbon` | :heavy_multiplication_x: | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold` (below). | +| `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 minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. | +| `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).| + +**Caution**: 🚩 For optimal UX, keep the settings for the mini-timeline in the `live` and `media_viewer` identical. Dragging the timeline may cause the card to change between the `live` view and `media_viewer` based views as the user pans between the past and present -- if the settings are different the timeline must "reset". + #### Media Viewer Controls: Title All configuration is under: @@ -485,25 +635,56 @@ media_viewer: | `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 + -The `event_gallery` is used for providing an overview of all `clips` and `snapshots` in a thumbnail gallery. +### Media Gallery Options + +The `media_gallery` is used for providing an overview of all `clips`, `snapshots` and `recordings` in a thumbnail gallery. All configuration is under: ```yaml -event_gallery: +media_gallery: ``` -See the [fully expanded event gallery configuration example](#config-expanded-event-gallery) for how these parameters are structured. +See the [fully expanded media gallery configuration example](#config-expanded-media-gallery) for how these parameters are structured. | Option | Default | Overridable | Description | | - | - | - | - | -| `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.| +| `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_gallery` (e.g. `clips`, `snapshots`, `recordings`). See [actions](#actions) below.| + +#### Media Gallery Controls: Filter + +All configuration is under: + +```yaml +media_gallery: + controls: + filter: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `mode` | `right` | :heavy_multiplication_x: | Whether to show the gallery media filter to the `left`, to the `right` or `none` for no media filter. | + +#### Media Gallery Controls: Thumbnails + +All configuration is under: + +```yaml +media_gallery: + controls: + thumbnails: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the gallery. Must be >= `75` and <= `175`.| +| `show_details` | `false` | :heavy_multiplication_x: | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail.| +| `show_download_control` | `true` | :heavy_multiplication_x: | Whether to show the download control on each 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 @@ -522,6 +703,8 @@ See the [fully expanded image configuration example](#config-expanded-image) for | `refresh_seconds` | 0 | :white_check_mark: | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. | | `actions` | | :white_check_mark: | Actions to use for the `image` view. See [actions](#actions) below.| +**Note**: When `mode` is set to `camera` this is effectively providing the same image as the `image` live provider would show in the live camera carousel. + ### Timeline Options The `timeline` is used to show the timing sequence of events and recordings across cameras. You can interact with the timeline in a number of ways: @@ -540,8 +723,9 @@ See the [fully expanded timeline configuration example](#config-expanded-timelin | Option | Default | Overridable | Description | | - | - | - | - | +| `style` | `stack` | :heavy_multiplication_x: | Whether the timeline should show events as a single flat `ribbon` or a `stack` of events that are clustered using the `clustering_threshold` (below). | | `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. | -| `clustering_threshold` | `3` | :heavy_multiplication_x: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.| +| `clustering_threshold` | `3` | :heavy_multiplication_x: | The minimum number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. Depending on the timescale/zoom of the timeline, the underlying timeline library may still allow overlaps for low values of this parameter -- for a fully "flat" timeline use the `ribbon` style. `0` disables clustering entirely. Only used in the `stack` style of timeline. | | `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.| @@ -561,8 +745,9 @@ timeline: | `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.| +| `show_download_control` | `true` | :heavy_multiplication_x: | Whether to show the download control on each 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.| @@ -571,7 +756,10 @@ timeline: 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. +media. This only applies to the card in normal render mode -- when in +fullscreen, or when in expanded (popup/dialog mode) the aspect ratio is chosen +dynamically to maximize the amount of content shown. + All configuration is under: @@ -581,11 +769,13 @@ 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` | :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.| +| `max_height` | `100vh` | :white_check_mark: | The maximum allowable height for the card. Specified in [CSS units](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Values_and_units). Generally users should not need to change this setting unless they have set an `unconstrained` aspect ratio. | +| `min_height` | `100px` | :white_check_mark: | The minimum allowable height for the card. Specified in [CSS units](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Values_and_units). Generally users should not need to change this setting. | + #### `dimensions.aspect_ratio_mode`: @@ -616,6 +806,89 @@ The card aspect ratio can be changed with the `dimensions.aspect_ratio_mode` and If no aspect ratio is specified or available, but one is needed then `16:9` will be used by default. + + +### Performance Options + +These options control the card performance settings to enable the card to run +(more) smoothly on lower end devices. + +All configuration is under: + +```yaml +performance: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `profile` | `high` | :heavy_multiplication_x: | Whether the card is configured in full `high` performance mode, or `low` performance defaults for lower end devices. See [low performance profile](#performance-profile-low) below.| + +#### Feature Options + +Controls card-wide central functionality that may impact performance but which is not configurable elsewhere. + +All configuration is under: + +```yaml +performance: + features: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `animated_progress_indicator` | `true` | :heavy_multiplication_x: | Will show the animated progress indicator 'spinner' when `true` or a simple loading icon when `false`.| +| `media_chunk_size` | `50` | :heavy_multiplication_x: | How many media items to fetch and render at a time (e.g. thumbnails under a live view, or number of snapshots to load in the media viewer). This may only make partial sense in some contexts (e.g. the 'infinite gallery' is still infinite, just loads thumbnails this many items at a time) or not at all (e.g. the timeline will show the number of events dictated by the time span the user navigates to).| + +#### Style Options + +Style performance options request the card minimize certain expensive CSS +stylings. This does not necessarily disable these stylings _entirely_ since that +may break the basic expected visuals of the card (e.g. menu icons need curves), +but rather avoids use of them in high item-count situations (e.g. avoiding +shadows on timeline items, or curves in the media gallery items). + +All configuration is under: + +```yaml +performance: + style: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `border_radius` | `true` | :heavy_multiplication_x: | If `false` minimizes the usage of rounded corners.| +| `box_shadow` | `true` | :heavy_multiplication_x: | If `false` minimizes the usage of shadows.| + + + +#### Performance Profile `low` + +In the `low` performance profile, the card attempts to lower the CPU and network +consumption of the card by setting default option values when they have not been explicitly set by the user. + +Principles used in the selection of options set by `low` profile mode: + +* Get 'out of the box' performance similar to the basic "Home Assistant Picture Glance" card. +* Only change behavior that the user can case-by-case 'reset' by explicitly setting an option elsewhere. +* Do not break the visual aesthetic of the card. + +**Note:**: Since the performance profile changes the _default_ value of options, +setting the `low` profile on a pre-existing card could have no effect if there are +considerable options already set by the user. + +Please see the source code for an exhaustive list of options set by `low` profile mode. Summary: + +* The default live provider (`auto`) will resolve to the `image` live provider for cameras with a `camera_entity` specified. It will have a refresh period of 10 seconds (same as the stock Home Assistant Picture Glance card). +* No event thumbnails fetched. +* No recordings shown. +* No automated actions (e.g. mute, play, pause) except playing in live view. +* Always lazily unload anything that can be unloaded. +* Carousels are not draggable and have no 'slide' effects. +* Live image is not shown during stream loads. +* No title popups. +* Menu rendered outside the main body of the card, with reduced menu buttons. +* All optional performace features and performance styles (described above) disabled. + ### Overrides Options @@ -674,6 +947,21 @@ If multiple cameras are configured in the card, use [overrides](#overrides) to c See [media layout examples](#media-layout-examples). + + +### Other Options + +All listed configuration options are under the top level, e.g.: + +```yaml +type: custom:frigate-card +... +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `card_id` | | :heavy_multiplication_x: | **Advanced users only**: An optional ID to uniquely identify this card. For use when actions are being sent to card(s) via the [query string](#query-string-actions). Must exclusively consist of these characters: `[a-zA-Z0-9_]`.| + ### Using AlexxIT's WebRTC Card @@ -688,6 +976,12 @@ events/snapshots/UI. A perfect combination! #### Specifying The WebRTC Card Camera +##### Frigate v0.12 and onwards + +If you have used the [recommended go2rtc setup](https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/) for Frigate, no additional `webrtc_card` configuration is necessary. + +##### Frigate v0.11 and earlier + The WebRTC Card live provider does **not** support use of Frigate-provided camera entities, as it requires an RTSP stream which Frigate does not currently provide. There are two ways to specify the WebRTC Card source camera: @@ -714,12 +1008,12 @@ cameras: url: 'rtsp://USERNAME:PASSWORD@CAMERA:554/RTSP_PATH' ``` -Other WebRTC Card options may be specified under the `live` section, like so: +Other WebRTC Card options may be specified under the `webrtc_card` section, like so: ```yaml -live: - webrtc_card: - ui: true +cameras: + - webrtc_card: + ui: true ``` See [the WebRTC Card live configuration](#webrtc-live-configuration) above, and the @@ -740,8 +1034,10 @@ All variables listed are under a `conditions:` section. | `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.| +| `expand` | If `true` the condition is satisfied if the card is in expanded mode (in a dialog/popup). If `false` the condition is satisfied if the card is **NOT** in expanded mode (in a dialog/popup).| | `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).| +| `media_loaded` | 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).| +| `media_query` | Any valid [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) string. Media queries must start and end with parentheses. This may be used to alter card configuration based on device/media properties (e.g. viewport width, orientation). Please note that `width` and `height` refer to the entire viewport not just the card. See the [media query example](#media-query-example).| See the [example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used. @@ -882,21 +1178,24 @@ Parameters for the `custom:frigate-card-ptz` element: | Parameter | Description | | - | - | | `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`.| +| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_select`, `expand`.| -##### Command descriptions + + +##### Action descriptions | Value | Description | | - | - | | `default` | Trigger the default view. | -| `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots` | Trigger the named [view](#views).| +| `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots` | Trigger the named [view](#views).| |`download`|Download the displayed media.| -|`frigate_ui`|Open the Frigate UI at the configured URL.| +|`camera_ui`|Open the Frigate UI at the configured URL.| |`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 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. | - +|`live_substream_select`| Perform a media player action. Takes a `camera` parameter with the [camera ID](#camera-ids) of the substream camera. | +|`expand`| Expand the card into a dialog/popup. | @@ -907,10 +1206,12 @@ This card supports several different views: | Key | Description | | ------------- | --------------------------------------------- | |`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 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 a Media viewer for the most recent clip for this camera/zone/label. Can also be accessed by holding down the `clips` menu icon.| +|`snapshots`|Shows a gallery of snapshots for this camera.| +|`snapshot`|Shows a viewer for the most recent snapshot for this camera. Can also be accessed by holding down the `snapshots` menu icon.| +|`clips`|Shows a gallery of clips for this camera.| +|`clip`|Shows a viewer for the most recent clip for this camera. Can also be accessed by holding down the `clips` menu icon.| +|`recordings`|Shows a gallery of recent (last day) recordings for this camera and its dependents.| +|`recording`|Shows a viewer for the most recent recording for this camera. Can also be accessed by holding down the `recordings` 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 @@ -971,8 +1272,8 @@ view. | Configuration path | Views to which it refers | | - | - | | `view.actions` | All (may be overriden by the below) | -| `media_viewer.actions` | `clip`, `snapshot` | -| `event_gallery.actions` | `clips`, `snapshots` | +| `media_gallery.actions` | `clips`, `snapshots`, `recordings` | +| `media_viewer.actions` | `clip`, `snapshot`, `recording` | | `live.actions` | `live` | | `image.actions` | `image` | @@ -999,7 +1300,8 @@ This card supports several menu styles. | ------------- | --------------------------------------------- | - | |`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 | +|`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 | +|`hover-card`| Overlay the menu over the card contents when the mouse is over the **card**, 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 | @@ -1122,6 +1424,38 @@ Pan around a large camera view to only show part of the video feed in the card a Media Layout B +### Video Scrubbing + +Video Scrubbing + +### Media filtering + +Media Filtering + +### Seamless integration of different camera sources/engines + +MotionEye Support + +### Expanded mode + +Expanded Mode + +### Substream Support + +Substream Support + +### Timeline Date Picking + +Timeline Date Picking + +### Low performance mode + +Low Performance Mode + +### Ribbon timeline + +Ribbon Timeline + ## Examples ### Illustrative Expanded Configuration Reference @@ -1139,12 +1473,16 @@ Reference: [Camera Options](#camera-options). cameras: - camera_entity: camera.front_Door live_provider: ha + engine: auto + hide: false frigate: url: http://my.frigate.local client_id: frigate camera_name: front_door - label: person - zone: steps + labels: + - person + zones: + - steps # Show events for camera-2 when this camera is viewed. dependencies: all_cameras: false @@ -1157,16 +1495,21 @@ cameras: - binary_sensor.front_door_sensor - camera_entity: camera.entrance live_provider: webrtc-card + engine: auto frigate: url: http://my-other.frigate.local client_id: frigate-other camera_name: entrance - label: car - zone: driveway + labels: + - car + zones: + - driveway icon: 'mdi:car' title: 'Front entrance' # Custom identifier for the camera to refer to it above. id: 'camera-2' + # Don't show this camera on the UI (will only be available as a dependent substream). + hide: true webrtc_card: entity: camera.entrance_rtsp url: 'rtsp://username:password@camera:554/av_stream/ch0' @@ -1177,6 +1520,106 @@ cameras: - binary_sensor.entrance_sensor dependencies: all_cameras: false + - camera_entity: camera.sitting_room + live_provider: go2rtc + go2rtc: + modes: + - webrtc + - mse + - mp4 + - mjpeg + stream: sitting_room + - camera_entity: camera.sitting_room_webrtc_card + live_provider: webrtc_card + webrtc_card: + # Arbitrary WebRTC Card options, see https://github.com/AlexxIT/WebRTC#configuration . + entity: camera.sitting_room_rtsp + ui: true + - camera_entity: camera.kitchen + live_provider: jsmpeg + 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 + - camera_entity: camera.back_yard + live_provider: image + image: + refresh_seconds: 1 + - camera_entity: camera.office_motioneye + motioneye: + images: + directory_pattern: '%Y-%m-%d' + file_pattern: '%H-%M-%S' + movies: + directory_pattern: '%Y-%m-%d' + file_pattern: '%H-%M-%S' +``` + + +
+ Expand: Cameras Global section + +Reference: [Cameras Global Options](#camera-global-options). + +```yaml +cameras_global: + live_provider: ha + engine: auto + hide: false + frigate: + url: http://my.frigate.local + client_id: frigate + camera_name: front_door + labels: + - person + zones: + - steps + dependencies: + all_cameras: false + cameras: + - camera-2 + triggers: + motion: false + occupancy: true + entities: + - binary_sensor.front_door_sensor + go2rtc: + modes: + - webrtc + - mse + - mp4 + - mjpeg + stream: sitting_room + webrtc_card: + # Arbitrary WebRTC Card options, see https://github.com/AlexxIT/WebRTC#configuration . + entity: camera.sitting_room_rtsp + 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 + image: + refresh_seconds: 1 ```
@@ -1245,6 +1688,10 @@ menu: enabled: true alignment: matching icon: mdi:video-switch + substreams: + priority: 50 + enabled: true + icon: mdi:video-input-component live: priority: 50 enabled: true @@ -1275,7 +1722,7 @@ menu: enabled: true alignment: matching icon: mdi:download - frigate_ui: + camera_ui: priority: 50 enabled: true alignment: matching @@ -1285,6 +1732,11 @@ menu: enabled: true alignment: matching icon: mdi:fullscreen + expand: + priority: 50 + enabled: true + alignment: matching + icon: mdi:arrow-expand-all media_player: priority: 50 enabled: false @@ -1312,23 +1764,6 @@ live: 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 @@ -1337,9 +1772,17 @@ live: media: clips size: 100 show_details: false + show_download_control: true show_favorite_control: true show_timeline_control: true mode: none + timeline: + style: ribbon + mode: none + clustering_threshold: 3 + media: all + show_recordings: true + window_seconds: 3600 title: mode: popup-bottom-right duration_seconds: 2 @@ -1379,6 +1822,7 @@ media_viewer: auto_unmute: never lazy_load: true draggable: true + snapshot_click_plays_clip: true transition_effect: slide controls: next_previous: @@ -1388,8 +1832,16 @@ media_viewer: size: 100 mode: none show_details: false + show_download_control: true show_favorite_control: true show_timeline_control: true + timeline: + style: ribbon + mode: none + clustering_threshold: 3 + media: all + show_recordings: true + window_seconds: 3600 title: mode: popup-bottom-right duration_seconds: 2 @@ -1413,19 +1865,22 @@ media_viewer: ``` - +
- Expand: Event Gallery section + Expand: Media Gallery section -Reference: [Event Gallery Options](#event-gallery-options). +Reference: [Media Gallery Options](#media-gallery-options). ```yaml -event_gallery: +media_gallery: controls: + filter: + mode: 'right' thumbnails: size: 100 show_details: false + show_download_control: true show_favorite_control: true show_timeline_control: true actions: @@ -1690,7 +2145,7 @@ elements: - entity: light.office_main_lights state: on state_not: off - mediaLoaded: true + media_loaded: true # Full form PTZ actions (only left button shown). - type: custom:frigate-card-ptz orientation: vertical @@ -1779,7 +2234,7 @@ elements: title: Open Frigate UI tap_action: action: custom:frigate-card-action - frigate_card_action: frigate_ui + frigate_card_action: camera_ui - type: custom:frigate-card-menu-icon icon: mdi:alpha-j-circle title: Change to fullscreen @@ -1829,6 +2284,8 @@ Reference: [Dimension Options](#dimensions-options). dimensions: aspect_ratio_mode: dynamic aspect_ratio: 16:9 + max_height: 100vh + min_height: 100px ```
@@ -1841,6 +2298,7 @@ Reference: [Timeline Options](#timeline-options). ```yaml timeline: + style: stack clustering_threshold: 3 media: all show_recordings: true @@ -1850,6 +2308,7 @@ timeline: mode: left size: 100 show_details: true + show_download_control: true show_favorite_control: true show_timeline_control: true ``` @@ -1881,6 +2340,60 @@ overrides: state: on state_not: off overrides: + cameras: + # As this is an array, we need to carefully ensure we are + # overridding the correct index. We do this by specifying + # earlier indicies as being overridden with an empty object + # (in YAML this is `{}`). In this example, overriddes will + # only apply to the 2nd camera: + - {} # No overrides for camera index 0. + - live_provider: 'ha' # Overrides for camera index 1. + engine: auto + hide: false + frigate: + url: http://my.frigate.local + client_id: frigate + camera_name: front_door + labels: + - person + zones: + - steps + dependencies: + all_cameras: false + cameras: + - camera-2 + triggers: + motion: false + occupancy: true + entities: + - binary_sensor.front_door_sensor + go2rtc: + modes: + - webrtc + - mse + - mp4 + - mjpeg + stream: sitting_room + webrtc_card: + # Arbitrary WebRTC Card options, see https://github.com/AlexxIT/WebRTC#configuration . + entity: camera.sitting_room_rtsp + 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 + image: + refresh_seconds: 1 live: webrtc_card: ui: true @@ -1906,6 +2419,7 @@ overrides: media: clips size: 100 show_details: false + show_download_control: true show_favorite_control: true show_timeline_control: true mode: none @@ -1956,6 +2470,11 @@ overrides: enabled: true alignment: matching icon: mdi:camera + recordings: + priority: 50 + enabled: false + alignment: matching + icon: mdi:album image: priority: 50 # Disable the image button. @@ -1973,7 +2492,7 @@ overrides: enabled: true alignment: matching icon: mdi:download - frigate_ui: + camera_ui: priority: 50 enabled: true alignment: matching @@ -2031,6 +2550,34 @@ overrides: ``` + +
+ Expand: Performance section + +Reference: [Performance Options](#performance-options). + +```yaml +performance: + profile: high + features: + animated_progress_indicator: true + media_chunk_size: 50 + style: + border_radius: true + box_shadow: true +``` +
+ +
+ Expand: Other options + +Reference: [Other Options](#other-options). + +```yaml +card_id: main +``` +
+ ### Basic cameras configuration
@@ -2056,7 +2603,7 @@ to provide a separate unambiguous way of referring to that camera, since the type: custom:frigate-card cameras: - camera_entity: camera.front_door - live_provider: frigate-jsmpeg + live_provider: jsmpeg title: Front Door (JSMPEG) - camera_entity: camera.front_door live_provider: webrtc-card @@ -2455,8 +3002,6 @@ overrides:
- -
Expand: Change the menu position based on HA state @@ -2472,9 +3017,6 @@ overrides: ```
- - -
Expand: Change the default view based on HA state @@ -2505,6 +3047,25 @@ overrides: ```
+ +
+ Expand: Change the menu style in expanded mode + +This example changes the menu style to `overlay` in expanded mode in order to take +advantage of the extra horizontal space of the dialog/popup. + +```yaml +menu: + style: hidden +overrides: + - conditions: + expand: true + overrides: + menu: + style: overlay +``` +
+ ### Refreshing a static image
@@ -2559,7 +3120,7 @@ This example shows the native PTZ element when the `live` or `image` view is dis elements: - type: custom:frigate-card-conditional conditions: - mediaLoaded: true + media_loaded: true view: - live - image @@ -2586,6 +3147,66 @@ elements: ```
+### Using live substreams + +The card supports configuring 'substreams' to show up for a given live camera through the use of [camera dependencies](#camera-dependencies-configuration). + +
+ Expand: Having an SD and HD substream + +This example shows two substreams for a single live camera, and uses the 'HD' icon. + +```yaml +[...] +cameras: + - camera_entity: camera.sitting_room + live_provider: image + dependencies: + cameras: + - sitting_room_hd + - camera_entity: camera.sitting_room + title: Sitting Room HD + live_provider: go2rtc + id: sitting_room_hd + # Do not show the HD camera independently on the UI. + hide: true +menu: + buttons: + substreams: + icon: mdi:high-definition +``` +
+ +
+ Expand: Having a substream menu with different live providers + +This example shows a substream menu for three different live providers for a given camera. + +```yaml +[...] +cameras: + - camera_entity: camera.sitting_room + live_provider: image + dependencies: + cameras: + - sitting_room_go2rtc + - sitting_room_ha + icon: mdi:image + - camera_entity: camera.sitting_room + live_provider: go2rtc + id: sitting_room_go2rtc + hide: true + title: Sitting Room go2rtc + icon: mdi:alpha-g + - camera_entity: camera.sitting_room + live_provider: ha + id: sitting_room_ha + hide: true + title: Sitting Room HA + icon: mdi:home +``` +
+ ### Using `card-mod` to style the card @@ -2639,7 +3260,7 @@ menu: ### 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). +`dependencies.cameras` allows events/recordings 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 @@ -2789,8 +3410,146 @@ overrides: ```
+ - +### Using Media Query conditions + +Alter the card configuration based on device or viewport properties. + +
+ Expand: Hide menu & controls when viewport width <= 300 (e.g. PIP mode) + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.back_yard + - camera_entity: camera.sitting_room +overrides: + - conditions: + media_query: '(max-width: 300px)' + overrides: + menu: + style: none + live: + controls: + next_previous: + style: none + thumbnails: + mode: none +``` +
+ +
+ Expand: Change menu position when orientation changes + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.back_yard + - camera_entity: camera.sitting_room +menu: + style: overlay +overrides: + - conditions: + media_query: '(orientation: landscape)' + overrides: + menu: + position: left +``` +
+ +### Automatically trigger "fullscreen" mode + +The card cannot automatically natively trigger fullscreen mode without the user +clicking, since Javascript (understandbly) prevents random websites from +triggering fullscreen mode without the user having activated it. + +There is a potential workaround: + +
+ Expand: Use "browser_mod" to show a popup with a Frigate Card + +This workaround uses +[hass-browser_mod](https://github.com/thomasloven/hass-browser_mod) with an +automation to trigger a popup. Thanks to +[conorlap@](https://github.com/conorlap) for the following example: + +```yaml +alias: >- + Doorbell Pressed OR Human Detected - Firefox browser full screen video feed + for 15 seconds +description: "" +trigger: + - platform: state + from: "off" + to: "on" + entity_id: + - binary_sensor.frontdoor_person_occupancy + - platform: state + entity_id: + - binary_sensor.front_door_dahua_button_pressed + to: "on" +condition: [] +action: + - service: browser_mod.popup + data: + size: wide + timeout: 15000 + content: + type: custom:frigate-card + aspect_ratio: 55% + cameras: + - camera_entity: camera.frontdoor + live_provider: ha + menu: + style: none + live: + controls: + title: + mode: none + target: + device_id: + - d0e93101edfg44y3yt35y5y45y54y +mode: single +``` +
+ + + +### Passing the card actions from the URL + +The card can respond to actions in the query string (see [below](#query-string-actions)). + +
+ Expand: Selecting the kitchen camera and opening the expanded view + +This example assumes the dashboard URL is `https://ha.mydomain.org/lovelace-test/0`. + +``` +https://ha.mydomain.org/lovelace-test/0?frigate-card-action:camera_select=kitchen&frigate-card-action:expand +``` +
+ +
+ Expand: Choosing the clips view on a named card + +This example assumes the dashboard URL is `https://ha.mydomain.org/lovelace-test/0`. + +It assumes that one card (of potentially multiple Frigate Cards on the dashboard) is configured with a `card_id` parameter: + +```yaml +type: custom:frigate-card +card_id: main +cameras: +[...] +``` + +``` +https://ha.mydomain.org/lovelace-test/0?frigate-card-action:main:clips +``` +
+ + ## Card Refreshes @@ -2850,6 +3609,58 @@ view: timeout_seconds: 30 ``` + + +### Passing the card actions from the URL + +It is possible to pass the Frigate card one or more actions from the URL (e.g. select a particular camera, open the live view in expanded mode, etc). + +To send an action to *all* Frigate cards on a dashboard: + +``` +[PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action:[ACTION]=[VALUE] +``` + +To send an action to a named Frigate card on the dashboard: + +``` +[PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action:[CARD_ID]:[ACTION]=[VALUE] +``` + +| Parameter | Description | +| - | - | +| `ACTION` | One of the supported Frigate Card custom actions (see below). | +| `CARD_ID` | When specified only cards that have a `card_id` parameter will act. | +| `VALUE` | An optional value to use with the `camera_select` and `live_substream_select` actions. | + +#### Actions + +| Action | Supported in query string | Explanation | +| - | - | - | +| `camera_select` | :white_check_mark: | | +| `camera_ui`| :white_check_mark: | | +| `clip` | :white_check_mark: | | +| `clips` | :white_check_mark: | | +| `default` | :white_check_mark: | | +| `download`| :heavy_multiplication_x: | Latest media information is not available on initial render. | +| `expand` | :white_check_mark: | | +| `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. | +| `image` | :white_check_mark: | | +| `live_substream_select` | :white_check_mark: | | +| `live` | :white_check_mark: | | +| `media_player`| :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/frigate-hass-card/issues) if you need this. | +| `menu_toggle` | :white_check_mark: | | +| `recording` | :white_check_mark: | | +| `recordings` | :white_check_mark: | | +| `snapshot` | :white_check_mark: | | +| `snapshots` | :white_check_mark: | | + +See [custom actions](#custom-actions) for a description of what the actions do. + +#### Examples + +See [query string examples](#query-string-examples) for examples of usage. + ### 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/). @@ -2877,7 +3688,7 @@ Using a `panel` dashboard with the following base configuration will result in t type: custom:frigate-card cameras: - camera_entity: camera.front_door - live_provider: frigate-jsmpeg + live_provider: jsmpeg dimensions: aspect_ratio: 1024:600 aspect_ratio_mode: static @@ -2893,6 +3704,22 @@ See [screenshot above](#screenshots-card-casting). 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. +### `Forbidden media source identifier` + +* If you are using a custom `client_id` setting in your `frigate.yml` file (the configuration file for the Frigate backend itself), you must tell the card about it. See [camera configuration](#camera-frigate-configuration"). +* You must have the `Enable the media browser` option enabled for the Frigate integration, in order for media fetches to work for the card. Media fetches are used to fetch events / clips / snapshots, etc. If you just wish to use live streams without media fetches, you can use the following configuration: + +```yaml +live: + controls: + thumbnails: + mode: none +``` + +### Static image URL with credentials doesn't load + +Your browser will not allow a page/script (like this card) to pass credentials to a cross-origin (different host) image URL for security reasons. There is no way around this unless you could also control the webserver that is serving the image to specifically allow `crossorigin` requests (which is typically not the case for an image served from a camera, for example). The stock Home Assistant Picture Glance card has the same limitation, for the same reasons. + ### 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. @@ -2903,13 +3730,6 @@ For some slowly loading cameras, for which [Home Assistant stream preloading](ht -### JSMPEG Live Camera Only Shows A 'spinner' - -You must be using a version of the [Frigate integration](https://github.com/blakeblackshear/frigate-hass-integration) >= 2.1.0 -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 @@ -2951,7 +3771,7 @@ possible in carousels that use the Firefox video player (e.g. `clips` carousel, or live views that use the `frigate` or `webrtc-card` provider). The next and previous buttons may be used to navigate in these instances. -Dragging works as expected for snapshots, or for the `frigate-jsmpeg` provider. +Dragging works as expected for snapshots, or for the `jsmpeg` provider. ### Progress bar cannot be dragged in Safari @@ -2997,12 +3817,20 @@ This could be for any number of reasons. Chromecast devices can be quite picky o ### 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)). +This card uses [visjs](https://github.com/visjs/vis-timeline) -- a timeline library -- to show camera timelines. This library currently uses non-passive event-listeners. These warnings can be safely ignored in this instance and cannot easily be fixed in the underlying library. + +### Custom element does not exist + +This is usually a sign that the card is not correctly installed (i.e. the browser cannot find the Javascript). In cases where it works in some browsers / devices but not in others it may simply be an old browser / webview that does not support modern Javascript (this is occasionally seen on old Android hardware). In this latter case, you are out of luck. ## Development ### Building +This project uses [Volta](https://github.com/volta-cli/volta) to ensure a consistent version of Node and Yarn are used during development. If you install Volta in your environment, you should not need to worry about which version of both to choose. **Note:** the dev container already comes with Volta installed. + +However, if you are not using Volta, you can check the `volta` key in the [`package.json`](./package.json) for a reference on which version of Node and Yarn should be used. + ```sh $ git clone https://github.com/dermotduffy/frigate-hass-card $ cd frigate-hass-card @@ -3014,9 +3842,9 @@ Resultant build will be at `dist/frigate-hass-card.js`. This could be installed ### 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) +[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](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: +You can use the [VS Code Dev 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 @@ -3025,9 +3853,9 @@ You can use the [VS Code Remote - Containers](https://code.visualstudio.com/docs 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`) +* Home Assistant (port `8123` or the next available one) +* Frigate (ports `5000` or the next available one) +* MQTT (port `1883` or the next available one) As docker-compose containers. diff --git a/docker-compose.yml b/docker-compose.yml index 3940d253..bc9e5cc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ --- -version: '3' services: dev: - user: node init: true build: .devcontainer + entrypoint: /usr/local/share/docker-init.sh + command: sleep infinity env_file: - .env volumes: diff --git a/images/date-picker.gif b/images/date-picker.gif new file mode 100644 index 00000000..984a2dd0 Binary files /dev/null and b/images/date-picker.gif differ diff --git a/images/expanded.gif b/images/expanded.gif new file mode 100644 index 00000000..13a8dce7 Binary files /dev/null and b/images/expanded.gif differ diff --git a/images/media-filtering.gif b/images/media-filtering.gif new file mode 100644 index 00000000..fc1c8908 Binary files /dev/null and b/images/media-filtering.gif differ diff --git a/images/motioneye.gif b/images/motioneye.gif new file mode 100644 index 00000000..6d7785c1 Binary files /dev/null and b/images/motioneye.gif differ diff --git a/images/performance.png b/images/performance.png new file mode 100644 index 00000000..c59f21bc Binary files /dev/null and b/images/performance.png differ diff --git a/images/ribbon-timeline.png b/images/ribbon-timeline.png new file mode 100644 index 00000000..22097748 Binary files /dev/null and b/images/ribbon-timeline.png differ diff --git a/images/substream.gif b/images/substream.gif new file mode 100644 index 00000000..0651e658 Binary files /dev/null and b/images/substream.gif differ diff --git a/images/video-scrubbing.gif b/images/video-scrubbing.gif new file mode 100644 index 00000000..5caa9c56 Binary files /dev/null and b/images/video-scrubbing.gif differ diff --git a/package.json b/package.json index 0cbdd131..11cec416 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "frigate-hass-card", - "version": "4.0.0", + "version": "5.0.0", "description": "Frigate Lovelace Card for Home Assistant", "keywords": [ "frigate", @@ -17,30 +17,34 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^6.0.4", "@egjs/hammerjs": "^2.0.17", + "@graphiteds/core": "^1.9.6", + "@lit-labs/scoped-registry-mixin": "^1.0.1", "@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", + "date-fns-tz": "^1.3.7", + "embla-carousel": "^7.0.9", "embla-carousel-wheel-gestures": "^3.0.0", "home-assistant-js-websocket": "^8.0.0", "keycharm": "^0.4.0", "lit": "^2.3.1", + "lit-flatpickr": "^0.4.0", "lodash-es": "^4.17.21", "moment": "^2.29.4", "propagating-hammerjs": "^2.0.1", "quick-lru": "^6.1.0", "screenfull": "^6.0.2", "side-drawer": "^3.1.0", - "ts-toolbelt": "^9.6.0", "uuid": "^8.3.2", - "vis-data": "^7.1.3", + "vis-data": "^7.1.4", "vis-timeline": "^7.7.0", "vis-util": "^5.0.2", + "web-dialog": "^0.0.11", "xss": "^1.0.14", - "zod": "^3.19.0" + "zod": "^3.21.4" }, "devDependencies": { "@babel/core": "^7.19.0", @@ -48,18 +52,20 @@ "@babel/plugin-proposal-decorators": "^7.19.0", "@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-commonjs": "^22.0.2", - "@rollup/plugin-image": "^2.1.1", + "@rollup/plugin-image": "^3.0.2", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^13.3.0", "@rollup/plugin-replace": "^4.0.0", "@types/lodash-es": "^4.17.5", "@typescript-eslint/eslint-plugin": "^5.36.2", "@typescript-eslint/parser": "^5.36.2", + "@vitest/coverage-c8": "^0.29.8", "eslint": "^8.23.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-prettier": "^4.2.1", + "jsdom": "^21.1.1", "prettier": "^2.6.0", "rollup": "^2.79.0", "rollup-plugin-git-info": "^1.0.0", @@ -67,13 +73,24 @@ "rollup-plugin-styles": "^4.0.0", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.33.0", + "rollup-plugin-visualizer": "^5.8.2", "sass": "^1.54.9", - "typescript": "^4.8.3" + "ts-prune": "^0.10.3", + "typescript": "^4.9.5", + "vitest": "^0.29.8", + "vitest-mock-extended": "^1.1.3" }, "scripts": { "start": "rollup -c --watch", "build": "yarn run lint && yarn run rollup", "lint": "eslint 'src/**/*.ts'", - "rollup": "rollup -c" + "rollup": "rollup -c", + "prune": "ts-prune", + "test": "vitest run", + "coverage": "vitest run --coverage" + }, + "volta": { + "node": "18.14.0", + "yarn": "3.4.1" } } diff --git a/rollup.config.js b/rollup.config.js index 98cf2331..821de314 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,7 +1,6 @@ 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'; @@ -9,6 +8,7 @@ import styles from 'rollup-plugin-styles'; import image from '@rollup/plugin-image'; import replace from '@rollup/plugin-replace'; import gitInfo from 'rollup-plugin-git-info'; +import { visualizer } from 'rollup-plugin-visualizer'; const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1'; const dev = watch || process.env.DEV === 'true' || process.env.DEV === '1'; @@ -46,13 +46,10 @@ const plugins = [ }), commonjs({ include: 'node_modules/**', + sourceMap: false, }), typescript(), json({ exclude: 'package.json' }), - babel({ - babelHelpers: 'bundled', - exclude: 'node_modules/**', - }), replace({ preventAssignment: true, values: { @@ -61,6 +58,7 @@ const plugins = [ }), watch && serve(serveopts), !dev && terser(), + visualizer(), ]; /** @@ -68,19 +66,37 @@ const plugins = [ */ const config = { input: 'src/card.ts', + // Specifically want a facade created as HACS will attach a hacstag + // queryparameter to the resource. Without a facade when chunks re-import the + // card chunk, they'll refer to a 'different' copy of the card chunk without + // the hacstag, causing a re-download of the same content and functionality + // problems. + preserveEntrySignatures: 'strict', output: { - file: 'dist/frigate-hass-card.js', + entryFileNames: 'frigate-hass-card.js', + dir: 'dist', + chunkFileNames: (chunk) => { + // Add "lang-" to the front of the language chunk names for readability. + if ( + chunk.facadeModuleId && + chunk.facadeModuleId.match(/localize\/languages\/.*\.json/) + ) { + return 'lang-[name]-[hash].js'; + } + return '[name]-[hash].js'; + }, format: 'es', ...(dev && { sourcemap: true, }), }, plugins: plugins, - // These two files use this at the toplevel, which causes rollup warning - // spam on build: `this` has been rewritten to `undefined` + // These 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', + './node_modules/flatpickr/dist/esm/index.js': 'window', }, }; diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index b1e52948..d62eb71e 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -165,7 +165,7 @@ const getActionHandler = (): ActionHandler => { return actionhandler as ActionHandler; }; -export const actionHandlerBind = ( +const actionHandlerBind = ( element: ActionHandlerElement, options?: FrigateCardActionHandlerOptions, ): void => { diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts new file mode 100644 index 00000000..751c00b8 --- /dev/null +++ b/src/camera-manager/browse-media/engine-browse-media.ts @@ -0,0 +1,215 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig, ExtendedHomeAssistant } from '../../types'; +import { ViewMedia } from '../../view/media'; +import { + CameraManagerMediaCapabilities, + DataQuery, + EventQuery, + PartialEventQuery, + CameraConfigs, + CameraManagerCameraCapabilities, + QueryType, + CameraEndpoint, +} from '../types'; +import { EntityRegistryManager } from '../../utils/ha/entity-registry'; +import { CameraManagerEngine } from '../engine'; +import { GenericCameraManagerEngine } from '../generic/engine-generic'; +import { CameraInitializationError } from '../error'; +import { localize } from '../../localize/localize'; +import { Entity } from '../../utils/ha/entity-registry/types'; +import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager'; +import { + BROWSE_MEDIA_CACHE_SECONDS, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_VIDEO, + RichBrowseMedia, +} from '../../utils/ha/browse-media/types'; +import { BrowseMediaMetadata } from './types'; +import { rangesOverlap } from '../range'; +import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media'; +import { canonicalizeHAURL } from '../../utils/ha'; +import { RequestCache } from '../cache'; +import { BrowseMediaViewMediaFactory } from './media'; + +/** + * A utility method to determine if a browse media object matches against a + * start and end date. + * @param media The browse media object (with rich metadata). + * @param start The optional start date. + * @param end The optional end date. + * @returns `true` if the media falls within the provided dates. + */ +export const isMediaWithinDates = ( + media: RichBrowseMedia, + start?: Date, + end?: Date, +): boolean => { + // If no date is specified at all, everything matches. + const dateReference = start ?? end; + if (!dateReference) { + return true; + } + + // If there's no metadata, nothing matches. + if (!media._metadata) { + return false; + } + + // Determine if: + // - The media starts within the query timeframe. + // - The media ends within the query timeframe. + // - The media entirely encompasses the query timeframe. + return rangesOverlap( + { + start: media._metadata.startDate, + end: media._metadata.endDate, + }, + { + start: start ?? dateReference, + end: end ?? dateReference, + }, + ); +}; + +export const getViewMediaFromBrowseMediaArray = ( + browseMedia: RichBrowseMedia[], +): ViewMedia[] | null => { + const lookup: Map = new Map(); + for (const browseMediaItem of browseMedia) { + const cameraID = browseMediaItem._metadata?.cameraID; + if (!cameraID) { + continue; + } + + const mediaType = + browseMediaItem.media_class === MEDIA_CLASS_VIDEO + ? 'clip' + : browseMediaItem.media_class === MEDIA_CLASS_IMAGE + ? 'snapshot' + : null; + + if (!mediaType) { + continue; + } + const media = BrowseMediaViewMediaFactory.createEventViewMedia( + mediaType, + browseMediaItem, + cameraID, + ); + + if (media) { + const id = media.getID(); + const existing = lookup.get(id); + // De-duplicate events with precisely the same ID (same + // hour/minute/second) choosing clip > snapshot. + if ( + !existing || + (existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip') + ) { + lookup.set(id, media); + } + } + } + return [...lookup.values()]; +}; + +/** + * A base class for cameras that read events from HA BrowseMedia interface. + */ +export class BrowseMediaCameraManagerEngine + extends GenericCameraManagerEngine + implements CameraManagerEngine +{ + protected _cameraEntities: Map = new Map(); + protected _browseMediaManager: BrowseMediaManager; + protected _resolvedMediaCache: ResolvedMediaCache; + protected _requestCache: RequestCache; + + public constructor( + browseMediaManager: BrowseMediaManager, + resolvedMediaCache: ResolvedMediaCache, + requestCache: RequestCache, + ) { + super(); + this._browseMediaManager = browseMediaManager; + this._resolvedMediaCache = resolvedMediaCache; + this._requestCache = requestCache; + } + + public async initializeCamera( + hass: HomeAssistant, + entityRegistryManager: EntityRegistryManager, + cameraConfig: CameraConfig, + ): Promise { + const entity = cameraConfig.camera_entity + ? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity) + : null; + if (!entity || !cameraConfig.camera_entity) { + throw new CameraInitializationError( + localize('error.no_camera_entity'), + cameraConfig, + ); + } + this._cameraEntities.set(cameraConfig.camera_entity, entity); + return cameraConfig; + } + + public generateDefaultEventQuery( + _cameras: CameraConfigs, + cameraIDs: Set, + query: PartialEventQuery, + ): EventQuery[] | null { + return [ + { + type: QueryType.Event, + cameraIDs: cameraIDs, + ...query, + }, + ]; + } + + public async getMediaDownloadPath( + hass: ExtendedHomeAssistant, + _cameraConfig: CameraConfig, + media: ViewMedia, + ): Promise { + const contentID = media.getContentID(); + if (!contentID) { + return null; + } + const resolvedMedia = await resolveMedia(hass, contentID, this._resolvedMediaCache); + return resolvedMedia + ? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) } + : null; + } + + public getQueryResultMaxAge(query: DataQuery): number | null { + if (query.type === QueryType.Event) { + return BROWSE_MEDIA_CACHE_SECONDS; + } + return null; + } + + public getCameraCapabilities( + cameraConfig: CameraConfig, + ): CameraManagerCameraCapabilities | null { + const parentCapabilities = super.getCameraCapabilities(cameraConfig); + if (!parentCapabilities) { + return null; + } + return { + ...parentCapabilities, + supportsClips: true, + supportsSnapshots: true, + supportsTimeline: true, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities { + return { + canFavorite: false, + canDownload: true, + }; + } +} diff --git a/src/camera-manager/browse-media/media.ts b/src/camera-manager/browse-media/media.ts new file mode 100644 index 00000000..12f7803d --- /dev/null +++ b/src/camera-manager/browse-media/media.ts @@ -0,0 +1,85 @@ +import format from 'date-fns/format'; +import isEqual from 'lodash-es/isEqual'; +import { formatDateAndTime } from '../../utils/basic'; +import { RichBrowseMedia } from '../../utils/ha/browse-media/types'; +import { + ViewMedia, + EventViewMedia, + ViewMediaType, + VideoContentType, +} from '../../view/media'; +import { BrowseMediaMetadata } from '../browse-media/types'; + +class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia { + protected _browseMedia: RichBrowseMedia; + protected _id: string; + + constructor( + mediaType: ViewMediaType, + cameraID: string, + browseMedia: RichBrowseMedia, + ) { + super(mediaType, cameraID); + this._browseMedia = browseMedia; + + // Generate a custom ID that uses the start date (to allow multiple + // BrowseMedia objects (e.g. images and movies) to be de-duplicated). + if (browseMedia._metadata?.startDate) { + this._id = `${cameraID}/${format( + browseMedia._metadata.startDate, + 'yyyy-MM-dd HH:mm:ss', + )}`; + } else { + this._id = browseMedia.media_content_id; + } + } + + public getStartTime(): Date | null { + return this._browseMedia._metadata?.startDate ?? null; + } + public getEndTime(): Date | null { + return null; + } + public getVideoContentType(): VideoContentType | null { + return VideoContentType.MP4; + } + public getID(): string { + return this._id; + } + public getContentID(): string { + return this._browseMedia.media_content_id; + } + public getTitle(): string | null { + const startTime = this.getStartTime(); + return startTime ? formatDateAndTime(startTime) : this._browseMedia.title; + } + public getThumbnail(): string | null { + return this._browseMedia.thumbnail; + } + public getWhat(): string[] | null { + return null; + } + public getScore(): number | null { + return null; + } + public getTags(): string[] | null { + return null; + } + public isGroupableWith(that: EventViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } +} + +export class BrowseMediaViewMediaFactory { + static createEventViewMedia( + mediaType: 'clip' | 'snapshot', + browseMedia: RichBrowseMedia, + cameraID: string, + ): BrowseMediaEventViewMedia | null { + return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia); + } +} diff --git a/src/camera-manager/browse-media/types.ts b/src/camera-manager/browse-media/types.ts new file mode 100644 index 00000000..09226f98 --- /dev/null +++ b/src/camera-manager/browse-media/types.ts @@ -0,0 +1,5 @@ +export interface BrowseMediaMetadata { + cameraID: string; + startDate: Date; + endDate: Date; +} diff --git a/src/camera-manager/cache.ts b/src/camera-manager/cache.ts new file mode 100644 index 00000000..7607aef7 --- /dev/null +++ b/src/camera-manager/cache.ts @@ -0,0 +1,167 @@ +import isEqual from 'lodash-es/isEqual'; +import orderBy from 'lodash-es/orderBy'; +import sortedUniqBy from 'lodash-es/sortedUniqBy'; +import { DateRange, MemoryRangeSet } from './range'; +import { DataQuery, QueryResults, RecordingSegment } from './types'; + +interface RequestCacheItem { + request: Request; + response: Response; + expires?: Date; +} + +interface CameraManagerCache { + get(request: Request): Response | null; + has(request: Request): boolean; + set(request: Request, response: Response, expiry?: Date): void; +} + +export class MemoryRequestCache + implements CameraManagerCache +{ + protected _data: RequestCacheItem[] = []; + + public get(request: Request): Response | null { + const now = new Date(); + for (const item of this._data) { + if ( + (!item.expires || now <= item.expires) && + this._contains(request, item.request) + ) { + return item.response; + } + } + return null; + } + + public clear(): void { + this._data = []; + } + + public has(request: Request): boolean { + return !!this.get(request); + } + + public set(request: Request, response: Response, expiry?: Date): void { + this._data.push({ + request: request, + response: response, + expires: expiry, + }); + + // Clean up old requests on set. + this._expireOldRequests(); + } + + protected _contains(a: Request, b: Request): boolean { + return isEqual(a, b); + } + + protected _expireOldRequests(): void { + const now = new Date(); + this._data = this._data.filter((item) => !item.expires || now < item.expires); + } +} + +export class RequestCache extends MemoryRequestCache {} + +class MemoryRangedCache { + protected _ranges: MemoryRangeSet = new MemoryRangeSet(); + protected _data: Data[] = []; + protected _timeFunc: (data: Data) => number; + protected _idFunc: (data: Data) => string; + + constructor(timeFunc: (data: Data) => number, idFunc: (data: Data) => string) { + this._timeFunc = timeFunc; + this._idFunc = idFunc; + } + + public add(range: DateRange, data: Data[]) { + this._ranges.add(range); + this._data = sortedUniqBy( + orderBy(this._data.concat(data), this._timeFunc, 'asc'), + this._idFunc, + ); + } + + public hasCoverage(range: DateRange): boolean { + return this._ranges.hasCoverage(range); + } + + public get(range: DateRange): Data[] | null { + if (!this.hasCoverage(range)) { + return null; + } + + const output: Data[] = []; + for (const data of this._data) { + const start = this._timeFunc(data); + if (start >= range.start.getTime()) { + if (start > range.end.getTime()) { + // Data is kept in order. + break; + } + output.push(data); + } + } + return output; + } + + public getSize(): number { + return this._data.length; + } + + /** + * Remove old data that matches a given predicate. No change to the covered + * ranges is made, i.e. this is asserting authoritiatively that this data does + * not exist in the current ranges. + * @param predicate A predicate to run on each data element. + */ + public expireMatches(predicate: (data: Data) => boolean): void { + this._data = this._data.filter((data) => !predicate(data)); + } +} + +export class RecordingSegmentsCache { + protected _segments: Map> = new Map(); + + public add(cameraID: string, range: DateRange, segments: RecordingSegment[]) { + let cameraSegmentCache: MemoryRangedCache | undefined = + this._segments.get(cameraID); + if (!cameraSegmentCache) { + cameraSegmentCache = new MemoryRangedCache( + (segment: RecordingSegment) => segment.start_time * 1000, + (segment: RecordingSegment) => segment.id, + ); + this._segments.set(cameraID, cameraSegmentCache); + } + cameraSegmentCache.add(range, segments); + } + + public clear(): void { + this._segments.clear(); + } + + public hasCoverage(cameraID: string, range: DateRange): boolean { + return !!this._segments.get(cameraID)?.hasCoverage(range); + } + + public get(cameraID: string, range: DateRange): RecordingSegment[] | null { + return this._segments.get(cameraID)?.get(range) ?? null; + } + + public getSize(cameraID: string): number | null { + return this._segments.get(cameraID)?.getSize() ?? null; + } + + public getCameraIDs(): string[] { + return [...this._segments.keys()]; + } + + public expireMatches( + cameraID: string, + func: (segment: RecordingSegment) => boolean, + ): void { + this._segments.get(cameraID)?.expireMatches(func); + } +} diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts new file mode 100644 index 00000000..dbacd8ca --- /dev/null +++ b/src/camera-manager/engine-factory.ts @@ -0,0 +1,101 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { localize } from '../localize/localize'; +import { CameraConfig, CardWideConfig } from '../types'; +import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager'; +import { BrowseMedia } from '../utils/ha/browse-media/types'; +import { EntityRegistryManager } from '../utils/ha/entity-registry'; +import { Entity } from '../utils/ha/entity-registry/types'; +import { ResolvedMediaCache } from '../utils/ha/resolved-media'; +import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache'; +import { CameraManagerEngine } from './engine'; +import { CameraInitializationError } from './error'; +import { Engine } from './types'; + +export class CameraManagerEngineFactory { + protected _entityRegistryManager: EntityRegistryManager; + protected _resolvedMediaCache: ResolvedMediaCache; + protected _cardWideConfig: CardWideConfig; + + constructor( + entityRegistryManager: EntityRegistryManager, + resolvedMediaCache: ResolvedMediaCache, + cardWideConfig: CardWideConfig, + ) { + this._entityRegistryManager = entityRegistryManager; + this._cardWideConfig = cardWideConfig; + this._resolvedMediaCache = resolvedMediaCache; + } + + public async createEngine(engine: Engine): Promise { + let cameraManagerEngine: CameraManagerEngine | null = null; + switch (engine) { + case Engine.Generic: + const { GenericCameraManagerEngine } = await import('./generic/engine-generic'); + cameraManagerEngine = new GenericCameraManagerEngine(); + break; + case Engine.Frigate: + const { FrigateCameraManagerEngine } = await import('./frigate/engine-frigate'); + cameraManagerEngine = new FrigateCameraManagerEngine( + this._cardWideConfig, + new RecordingSegmentsCache(), + new RequestCache(), + ); + break; + case Engine.MotionEye: + const { MotionEyeCameraManagerEngine } = await import( + './motioneye/engine-motioneye' + ); + cameraManagerEngine = new MotionEyeCameraManagerEngine( + new BrowseMediaManager(new MemoryRequestCache()), + this._resolvedMediaCache, + new RequestCache(), + ); + } + return cameraManagerEngine; + } + + public async getEngineForCamera( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): Promise { + let engine: Engine | null = null; + if (cameraConfig.engine === 'frigate') { + engine = Engine.Frigate; + } else if (cameraConfig.engine === 'motioneye') { + engine = Engine.MotionEye; + } else if (cameraConfig.engine === 'auto') { + const cameraEntity = cameraConfig.camera_entity; + + if (cameraEntity) { + let entity: Entity | null; + try { + entity = await this._entityRegistryManager.getEntity(hass, cameraEntity); + } catch (e) { + // Throw a slightly friendlier exception (as a typo in the entity is + // likely to be a common failure mode). + throw new CameraInitializationError( + localize('error.no_camera_entity'), + cameraConfig, + ); + } + + switch (entity?.platform) { + case 'frigate': + engine = Engine.Frigate; + break; + case 'motioneye': + engine = Engine.MotionEye; + break; + default: + engine = Engine.Generic; + } + } else if (cameraConfig.frigate.camera_name) { + // Frigate technically does not need an entity, if the camera name is + // manually set the camera is assumed to be Frigate. + engine = Engine.Frigate; + } + } + + return engine; + } +} diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts new file mode 100644 index 00000000..518113c8 --- /dev/null +++ b/src/camera-manager/engine.ts @@ -0,0 +1,139 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig, ExtendedHomeAssistant } from '../types'; +import { EntityRegistryManager } from '../utils/ha/entity-registry'; +import { ViewMedia } from '../view/media'; +import { + DataQuery, + EventQuery, + EventQueryResultsMap, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryReturnType, + RecordingQuery, + RecordingQueryResultsMap, + RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, + CameraManagerCameraCapabilities, + CameraManagerMediaCapabilities, + CameraManagerCameraMetadata, + CameraEndpointsContext, + CameraConfigs, + Engine, + CameraEndpoints, + MediaMetadataQuery, + MediaMetadataQueryResultsMap, + EngineOptions, + CameraEndpoint, +} from './types'; + +export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; + +export interface CameraManagerEngine { + getEngineType(): Engine; + + initializeCamera( + hass: HomeAssistant, + entityRegistryManager: EntityRegistryManager, + cameraConfig: CameraConfig, + ): Promise; + + generateDefaultEventQuery( + cameras: CameraConfigs, + cameraIDs: Set, + query: PartialEventQuery, + ): EventQuery[] | null; + + generateDefaultRecordingQuery( + cameras: CameraConfigs, + cameraIDs: Set, + query: PartialRecordingQuery, + ): RecordingQuery[] | null; + + generateDefaultRecordingSegmentsQuery( + cameras: CameraConfigs, + cameraIDs: Set, + query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null; + + getEvents( + hass: HomeAssistant, + cameras: CameraConfigs, + query: EventQuery, + engineOptions?: EngineOptions, + ): Promise; + + getRecordings( + hass: HomeAssistant, + cameras: CameraConfigs, + query: RecordingQuery, + engineOptions?: EngineOptions, + ): Promise; + + getRecordingSegments( + hass: HomeAssistant, + cameras: CameraConfigs, + query: RecordingSegmentsQuery, + engineOptions?: EngineOptions, + ): Promise; + + generateMediaFromEvents( + hass: HomeAssistant, + cameras: CameraConfigs, + query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null; + + generateMediaFromRecordings( + hass: HomeAssistant, + cameras: CameraConfigs, + query: RecordingQuery, + results: QueryReturnType, + ): ViewMedia[] | null; + + getMediaDownloadPath( + hass: ExtendedHomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + ): Promise; + + favoriteMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + favorite: boolean, + ): Promise; + + getQueryResultMaxAge(query: DataQuery): number | null; + + getMediaSeekTime( + hass: HomeAssistant, + cameras: CameraConfigs, + media: ViewMedia, + target: Date, + engineOptions?: EngineOptions, + ): Promise; + + getMediaMetadata( + hass: HomeAssistant, + cameras: CameraConfigs, + query: MediaMetadataQuery, + engineOptions?: EngineOptions, + ): Promise; + + getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata; + + getCameraCapabilities( + cameraConfig: CameraConfig, + ): CameraManagerCameraCapabilities | null; + + getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; + + getCameraEndpoints( + cameraConfig: CameraConfig, + context?: CameraEndpointsContext, + ): CameraEndpoints | null; +} diff --git a/src/camera-manager/error.ts b/src/camera-manager/error.ts new file mode 100644 index 00000000..61f9e7e5 --- /dev/null +++ b/src/camera-manager/error.ts @@ -0,0 +1,3 @@ +import { FrigateCardError } from '../types.js'; + +export class CameraInitializationError extends FrigateCardError {} diff --git a/src/camera-manager/frigate/assets/frigate-logo-dark.svg b/src/camera-manager/frigate/assets/frigate-logo-dark.svg new file mode 100644 index 00000000..16cd275c --- /dev/null +++ b/src/camera-manager/frigate/assets/frigate-logo-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts new file mode 100644 index 00000000..b501cbbf --- /dev/null +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -0,0 +1,1213 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import add from 'date-fns/add'; +import endOfHour from 'date-fns/endOfHour'; +import startOfHour from 'date-fns/startOfHour'; +import { CameraConfig, CardWideConfig, ExtendedHomeAssistant } from '../../types'; +import { ViewMedia } from '../../view/media'; +import { RecordingSegmentsCache, RequestCache } from '../cache'; +import { + CameraManagerEngine, + CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, +} from '../engine'; +import { DateRange } from '../range'; +import { + CameraManagerCameraMetadata, + CameraManagerCameraCapabilities, + CameraManagerMediaCapabilities, + DataQuery, + Engine, + EventQuery, + EventQueryResults, + EventQueryResultsMap, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryResults, + QueryResultsType, + QueryReturnType, + QueryType, + RecordingQuery, + RecordingQueryResults, + RecordingQueryResultsMap, + RecordingSegment, + RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, + CameraEndpointsContext, + CameraConfigs, + CameraEndpoints, + CameraEndpoint, + MediaMetadataQuery, + MediaMetadataQueryResults, + MediaMetadataQueryResultsMap, + EngineOptions, +} from '../types'; +import { + FrigateEventQueryResults, + FrigateRecordingQueryResults, + FrigateRecordingSegmentsQueryResults, + FrigateRecording, +} from './types'; +import { + getEvents, + getEventSummary, + getRecordingSegments, + getRecordingsSummary, + NativeFrigateEventQuery, + NativeFrigateRecordingSegmentsQuery, + retainEvent, +} from './requests'; +import isEqual from 'lodash-es/isEqual'; +import orderBy from 'lodash-es/orderBy'; +import throttle from 'lodash-es/throttle'; +import uniqWith from 'lodash-es/uniqWith'; +import { + allPromises, + formatDate, + prettifyTitle, + runWhenIdleIfSupported, +} from '../../utils/basic'; +import fromUnixTime from 'date-fns/fromUnixTime'; +import sum from 'lodash-es/sum'; +import { FrigateViewMediaClassifier } from './media-classifier'; +import { ViewMediaClassifier } from '../../view/media-classifier'; +import { FrigateViewMediaFactory } from './media'; +import { log } from '../../utils/debug'; +import { getEntityTitle } from '../../utils/ha'; +import { EntityRegistryManager } from '../../utils/ha/entity-registry'; +import { Entity } from '../../utils/ha/entity-registry/types'; +import { CameraInitializationError } from '../error'; +import { localize } from '../../localize/localize'; +import uniq from 'lodash-es/uniq'; +import format from 'date-fns/format'; +import { GenericCameraManagerEngine } from '../generic/engine-generic'; +import frigateLogo from './assets/frigate-logo-dark.svg'; + +const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60; +const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60; +const MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS = 60; + +const CAMERA_BIRDSEYE = 'birdseye' as const; + +class FrigateQueryResultsClassifier { + public static isFrigateEventQueryResults( + results: QueryResults, + ): results is FrigateEventQueryResults { + return results.engine === Engine.Frigate && results.type === QueryResultsType.Event; + } + + public static isFrigateRecordingQueryResults( + results: QueryResults, + ): results is FrigateRecordingQueryResults { + return ( + results.engine === Engine.Frigate && results.type === QueryResultsType.Recording + ); + } + + public static isFrigateRecordingSegmentsResults( + results: QueryResults, + ): results is FrigateRecordingSegmentsQueryResults { + return ( + results.engine === Engine.Frigate && + results.type === QueryResultsType.RecordingSegments + ); + } +} + +export class FrigateCameraManagerEngine + extends GenericCameraManagerEngine + implements CameraManagerEngine +{ + protected _recordingSegmentsCache: RecordingSegmentsCache; + protected _requestCache: RequestCache; + protected _cardWideConfig: CardWideConfig; + + // Garbage collect segments at most once an hour. + protected _throttledSegmentGarbageCollector = throttle( + this._garbageCollectSegments.bind(this), + 60 * 60 * 1000, + { leading: false, trailing: true }, + ); + + constructor( + cardWideConfig: CardWideConfig, + recordingSegmentsCache: RecordingSegmentsCache, + requestCache: RequestCache, + ) { + super(); + this._cardWideConfig = cardWideConfig; + this._recordingSegmentsCache = recordingSegmentsCache; + this._requestCache = requestCache; + } + + public getEngineType(): Engine { + return Engine.Frigate; + } + + public async initializeCamera( + hass: HomeAssistant, + entityRegistryManager: EntityRegistryManager, + cameraConfig: CameraConfig, + ): Promise { + const hasCameraName = !!cameraConfig.frigate?.camera_name; + const hasAutoTriggers = + cameraConfig.triggers.motion || cameraConfig.triggers.occupancy; + + let entity: Entity | null = null; + + // Entity information is required if the Frigate camera name is missing, or + // if the entity requires automatic resolution of motion/occupancy sensors. + if (cameraConfig.camera_entity && (!hasCameraName || hasAutoTriggers)) { + try { + entity = await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity); + } catch (e) { + throw new CameraInitializationError( + localize('error.no_camera_entity'), + cameraConfig, + ); + } + } + + if (entity && !hasCameraName) { + const resolvedName = this._getFrigateCameraNameFromEntity(entity); + if (resolvedName) { + cameraConfig.frigate.camera_name = resolvedName; + } + } + + if (hasAutoTriggers) { + // 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 = await entityRegistryManager.getMatchingEntities( + hass, + (ent) => + ent.config_entry_id === entity?.config_entry_id && + !ent.disabled_by && + ent.entity_id.startsWith('binary_sensor.'), + ); + + if (cameraConfig.triggers.motion) { + const motionEntity = this._getMotionSensor(cameraConfig, [ + ...binarySensorEntities.values(), + ]); + if (motionEntity) { + cameraConfig.triggers.entities.push(motionEntity); + } + } + + if (cameraConfig.triggers.occupancy) { + const occupancyEntities = this._getOccupancySensor(cameraConfig, [ + ...binarySensorEntities.values(), + ]); + if (occupancyEntities) { + cameraConfig.triggers.entities.push(...occupancyEntities); + } + } + + // De-duplicate triggering entities. + cameraConfig.triggers.entities = uniq(cameraConfig.triggers.entities); + } + + return cameraConfig; + } + + /** + * Get the Frigate camera name from an entity. + * @returns The Frigate camera name or null if unavailable. + */ + protected _getFrigateCameraNameFromEntity(entity: Entity): string | null { + if ( + entity.platform === 'frigate' && + entity.unique_id && + typeof entity.unique_id === 'string' + ) { + const match = entity.unique_id.match(/:camera:(?[^:]+)$/); + if (match && match.groups) { + return match.groups['camera']; + } + } + return null; + } + + /** + * Get the motion sensor entity for a given camera. + * @param cache The EntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the motion sensor or null. + */ + protected _getMotionSensor( + cameraConfig: CameraConfig, + entities: Entity[], + ): string | null { + if (cameraConfig.frigate.camera_name) { + return ( + entities.find( + (entity) => + typeof entity.unique_id === 'string' && + !!entity.unique_id?.match( + new RegExp(`:motion_sensor:${cameraConfig.frigate.camera_name}`), + ), + )?.entity_id ?? null + ); + } + return null; + } + + /** + * Get the occupancy sensor entity for a given camera. + * @param cache The EntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the occupancy sensor or null. + */ + protected _getOccupancySensor( + cameraConfig: CameraConfig, + entities: Entity[], + ): string[] | null { + const entityIDs: string[] = []; + const addEntityIDIfFound = (cameraOrZone: string, label: string): void => { + const entityID = + entities.find( + (entity) => + typeof entity.unique_id === 'string' && + !!entity.unique_id?.match( + new RegExp(`:occupancy_sensor:${cameraOrZone}_${label}`), + ), + )?.entity_id ?? null; + if (entityID) { + entityIDs.push(entityID); + } + }; + + if (cameraConfig.frigate.camera_name) { + // If zone(s) are specified, the master occupancy sensor for the overall + // camera is not used by default (but could be manually added by the + // user). + const camerasAndZones = cameraConfig.frigate.zones?.length + ? cameraConfig.frigate.zones + : [cameraConfig.frigate.camera_name]; + + const labels = cameraConfig.frigate.labels?.length + ? cameraConfig.frigate.labels + : ['all']; + for (const cameraOrZone of camerasAndZones) { + for (const label of labels) { + addEntityIDIfFound(cameraOrZone, label); + } + } + + if (entityIDs.length) { + return entityIDs; + } + } + return null; + } + + public async getMediaDownloadPath( + _hass: ExtendedHomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + ): Promise { + if (FrigateViewMediaClassifier.isFrigateEvent(media)) { + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/notifications/${media.getID()}/` + + `${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` + + `?download=true`, + sign: true, + }; + } else if (FrigateViewMediaClassifier.isFrigateRecording(media)) { + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/recording/${cameraConfig.frigate.camera_name}` + + `/start/${Math.floor(media.getStartTime().getTime() / 1000)}` + + `/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` + + `?download=true`, + sign: true, + }; + } + return null; + } + + public generateDefaultEventQuery( + cameras: CameraConfigs, + cameraIDs: Set, + query?: PartialEventQuery, + ): EventQuery[] | null { + const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) => + cameras.get(cameraID), + ); + + // If all cameras specify exactly the same zones or labels (incl. none), we + // can use a single batch query which will be better performance wise, + // otherwise we must fan out to multiple queries in order to precisely match + // the user's intent. + const uniqueZoneArrays = uniqWith( + relevantCameraConfigs.map((config) => config?.frigate.zones), + isEqual, + ); + const uniqueLabelArrays = uniqWith( + relevantCameraConfigs.map((config) => config?.frigate.labels), + isEqual, + ); + + if (uniqueZoneArrays.length === 1 && uniqueLabelArrays.length === 1) { + return [ + { + type: QueryType.Event, + cameraIDs: cameraIDs, + ...(uniqueLabelArrays[0] && { what: new Set(uniqueLabelArrays[0]) }), + ...(uniqueZoneArrays[0] && { where: new Set(uniqueZoneArrays[0]) }), + ...query, + }, + ]; + } + + const output: EventQuery[] = []; + for (const cameraID of cameraIDs) { + const cameraConfig = cameras.get(cameraID); + if (cameraConfig) { + output.push({ + type: QueryType.Event, + cameraIDs: new Set([cameraID]), + ...(cameraConfig.frigate.labels && { + what: new Set(cameraConfig.frigate.labels), + }), + ...(cameraConfig.frigate.zones && { + where: new Set(cameraConfig.frigate.zones), + }), + ...query, + }); + } + } + return output.length ? output : null; + } + + public generateDefaultRecordingQuery( + _cameras: CameraConfigs, + cameraIDs: Set, + query?: PartialRecordingQuery, + ): RecordingQuery[] { + return [ + { + type: QueryType.Recording, + cameraIDs: cameraIDs, + ...query, + }, + ]; + } + + public generateDefaultRecordingSegmentsQuery( + _cameras: CameraConfigs, + cameraIDs: Set, + query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null { + if (!query.start || !query.end) { + return null; + } + return [ + { + type: QueryType.RecordingSegments, + cameraIDs: cameraIDs, + start: query.start, + end: query.end, + ...query, + }, + ]; + } + + public async favoriteMedia( + hass: HomeAssistant, + cameraConfig: CameraConfig, + media: ViewMedia, + favorite: boolean, + ): Promise { + if (!FrigateViewMediaClassifier.isFrigateEvent(media)) { + return; + } + + await retainEvent(hass, cameraConfig.frigate.client_id, media.getID(), favorite); + media.setFavorite(favorite); + } + + protected _buildInstanceToCameraIDMapFromQuery( + cameras: CameraConfigs, + cameraIDs: Set, + ): Map> { + const output: Map> = new Map(); + for (const cameraID of cameraIDs) { + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + const clientID = cameraConfig?.frigate.client_id; + if (clientID) { + if (!output.has(clientID)) { + output.set(clientID, new Set()); + } + output.get(clientID)?.add(cameraID); + } + } + return output; + } + + protected _getFrigateCameraNamesForCameraIDs( + cameras: CameraConfigs, + cameraIDs: Set, + ): Set { + const output = new Set(); + for (const cameraID of cameraIDs) { + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (cameraConfig?.frigate.camera_name) { + output.add(cameraConfig.frigate.camera_name); + } + } + return output; + } + + public async getEvents( + hass: HomeAssistant, + cameras: CameraConfigs, + query: EventQuery, + engineOptions?: EngineOptions, + ): Promise { + const output: EventQueryResultsMap = new Map(); + + const processInstanceQuery = async ( + instanceID: string, + cameraIDs?: Set, + ): Promise => { + if (!cameraIDs || !cameraIDs.size) { + return; + } + const instanceQuery = { ...query, cameraIDs: cameraIDs }; + const cachedResult = + engineOptions?.useCache ?? true ? this._requestCache.get(instanceQuery) : null; + if (cachedResult) { + output.set(query, cachedResult as EventQueryResults); + return; + } + + const nativeQuery: NativeFrigateEventQuery = { + instance_id: instanceID, + cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)), + ...(query.what && { labels: Array.from(query.what) }), + ...(query.where && { zones: Array.from(query.where) }), + ...(query.tags && { sub_labels: Array.from(query.tags) }), + ...(query.end && { before: Math.floor(query.end.getTime() / 1000) }), + ...(query.start && { after: Math.floor(query.start.getTime() / 1000) }), + ...(query.limit && { limit: query.limit }), + ...(query.hasClip && { has_clip: query.hasClip }), + ...(query.hasSnapshot && { has_snapshot: query.hasSnapshot }), + ...(query.favorite && { favorites: query.favorite }), + limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT, + }; + + const result: FrigateEventQueryResults = { + type: QueryResultsType.Event, + engine: Engine.Frigate, + instanceID: instanceID, + events: await getEvents(hass, nativeQuery), + expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }), + cached: false, + }; + + if (engineOptions?.useCache ?? true) { + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + } + output.set(instanceQuery, result); + }; + + // Frigate allows multiple cameras to be searched for events in a single + // query. Break them down into groups of cameras per Frigate instance, then + // query once per instance for all cameras in that instance. + const instances = this._buildInstanceToCameraIDMapFromQuery( + cameras, + query.cameraIDs, + ); + + await Promise.all( + Array.from(instances.keys()).map((instanceID) => + processInstanceQuery(instanceID, instances.get(instanceID)), + ), + ); + return output.size ? output : null; + } + + public async getRecordings( + hass: HomeAssistant, + cameras: CameraConfigs, + query: RecordingQuery, + engineOptions?: EngineOptions, + ): Promise { + const output: RecordingQueryResultsMap = new Map(); + + const processQuery = async ( + baseQuery: RecordingQuery, + cameraID: string, + ): Promise => { + const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; + const cachedResult = + engineOptions?.useCache ?? true ? this._requestCache.get(query) : null; + if (cachedResult) { + output.set(query, cachedResult as RecordingQueryResults); + return; + } + + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return; + } + + const recordingSummary = await getRecordingsSummary( + hass, + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + ); + + let recordings: FrigateRecording[] = []; + + for (const dayData of recordingSummary ?? []) { + for (const hourData of dayData.hours) { + const hour = add(dayData.day, { hours: hourData.hour }); + const startHour = startOfHour(hour); + const endHour = endOfHour(hour); + if ( + (!query.start || startHour >= query.start) && + (!query.end || endHour <= query.end) + ) { + recordings.push({ + cameraID: cameraID, + startTime: startHour, + endTime: endHour, + events: hourData.events, + }); + } + } + } + + if (query.limit !== undefined) { + // Frigate does not natively support a way to limit recording searches so + // this simulates it. + recordings = orderBy( + recordings, + (recording: FrigateRecording) => recording.startTime, + 'desc', + ).slice(0, query.limit); + } + + const result: FrigateRecordingQueryResults = { + type: QueryResultsType.Recording, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + recordings: recordings, + expiry: add(new Date(), { + seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS, + }), + cached: false, + }; + if (engineOptions?.useCache ?? true) { + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + } + output.set(query, result); + }; + + // Frigate recordings can only be queried for a single camera, so fan out + // the inbound query into multiple outbound queries. + await Promise.all( + Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)), + ); + return output.size ? output : null; + } + + public async getRecordingSegments( + hass: HomeAssistant, + cameras: CameraConfigs, + query: RecordingSegmentsQuery, + engineOptions?: EngineOptions, + ): Promise { + const output: RecordingSegmentsQueryResultsMap = new Map(); + + const processQuery = async ( + baseQuery: RecordingSegmentsQuery, + cameraID: string, + ): Promise => { + const query = { ...baseQuery, cameraIDs: new Set([cameraID]) }; + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig || !cameraConfig.frigate.camera_name) { + return; + } + + const range: DateRange = { start: query.start, end: query.end }; + + // A note on Frigate Recording Segments: + // - There is an internal cache at the engine level for segments to allow + // caching "within an existing query" (e.g. if we already cached hour + // 1-8, we will avoid a fetch if we request hours 2-3 even though the + // query is different -- the segments won't be). This is since the + // volume of data in segment transfers can be high, and the segments can + // be used in high frequency situations (e.g. video seeking). + const cachedSegments = + engineOptions?.useCache ?? true + ? this._recordingSegmentsCache.get(cameraID, range) + : null; + if (cachedSegments) { + output.set(query, { + type: QueryResultsType.RecordingSegments, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + segments: cachedSegments, + cached: true, + }); + return; + } + + const request: NativeFrigateRecordingSegmentsQuery = { + instance_id: cameraConfig.frigate.client_id, + camera: cameraConfig.frigate.camera_name, + after: Math.floor(query.start.getTime() / 1000), + before: Math.floor(query.end.getTime() / 1000), + }; + + const segments = await getRecordingSegments(hass, request); + + if (engineOptions?.useCache ?? true) { + this._recordingSegmentsCache.add(cameraID, range, segments); + } + + output.set(query, { + type: QueryResultsType.RecordingSegments, + engine: Engine.Frigate, + instanceID: cameraConfig.frigate.client_id, + segments: segments, + cached: false, + }); + }; + + // Frigate recording segments can only be queried for a single camera, so + // fan out the inbound query into multiple outbound queries. + await Promise.all( + Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)), + ); + + runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras)); + return output.size ? output : null; + } + + protected _getCameraIDMatch( + cameras: CameraConfigs, + query: DataQuery, + instanceID: string, + cameraName: string, + ): string | null { + // If the query is only for a single cameraID, all results are assumed to + // belong to it for performance reasons. Otherwise, we need to map the + // instanceID and camera name for the known cameras, and get the precise + // cameraID that matches the expected instance ID / camera name. + if (query.cameraIDs.size === 1) { + return [...query.cameraIDs][0]; + } + for (const [cameraID, cameraConfig] of cameras.entries()) { + if ( + cameraConfig.frigate.client_id === instanceID && + cameraConfig.frigate.camera_name === cameraName + ) { + return cameraID; + } + } + return null; + } + + public generateMediaFromEvents( + _hass: HomeAssistant, + cameras: CameraConfigs, + query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) { + return null; + } + + const output: ViewMedia[] = []; + for (const event of results.events) { + const cameraID = this._getCameraIDMatch( + cameras, + query, + results.instanceID, + event.camera, + ); + if (!cameraID) { + continue; + } + const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID); + if (!cameraConfig) { + continue; + } + let mediaType: 'clip' | 'snapshot' | null = null; + if ( + !query.hasClip && + !query.hasSnapshot && + (event.has_clip || event.has_snapshot) + ) { + mediaType = event.has_clip ? 'clip' : 'snapshot'; + } else if (query.hasSnapshot && event.has_snapshot) { + mediaType = 'snapshot'; + } else if (query.hasClip && event.has_clip) { + mediaType = 'clip'; + } + if (!mediaType) { + continue; + } + const media = FrigateViewMediaFactory.createEventViewMedia( + mediaType, + cameraID, + cameraConfig, + event, + event.sub_label ? this._splitSubLabels(event.sub_label) : undefined, + ); + if (media) { + output.push(media); + } + } + return output; + } + + public generateMediaFromRecordings( + hass: HomeAssistant, + cameras: CameraConfigs, + _query: RecordingQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) { + return null; + } + + const output: ViewMedia[] = []; + for (const recording of results.recordings) { + const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID); + if (!cameraConfig) { + continue; + } + const media = FrigateViewMediaFactory.createRecordingViewMedia( + recording.cameraID, + recording, + cameraConfig, + this.getCameraMetadata(hass, cameraConfig).title, + ); + if (media) { + output.push(media); + } + } + return output; + } + + public getQueryResultMaxAge(query: DataQuery): number | null { + if (query.type === QueryType.Event) { + return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS; + } else if (query.type === QueryType.Recording) { + return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS; + } + return null; + } + + public async getMediaSeekTime( + hass: HomeAssistant, + cameras: CameraConfigs, + media: ViewMedia, + target: Date, + engineOptions?: EngineOptions, + ): Promise { + const start = media.getStartTime(); + const end = media.getEndTime(); + if (!start || !end || target < start || target > end) { + return null; + } + + const cameraID = media.getCameraID(); + const query: RecordingSegmentsQuery = { + cameraIDs: new Set([cameraID]), + start: start, + end: end, + type: QueryType.RecordingSegments, + }; + + const results = await this.getRecordingSegments(hass, cameras, query, engineOptions); + + if (results) { + return this._getSeekTimeInSegments( + start, + target, + // There will only be a single result since Frigate recording segments + // searches are per camera which is specified singularly above. + Array.from(results.values())[0].segments, + ); + } + return null; + } + + protected _getQueryableCameraConfig( + cameras: CameraConfigs, + cameraID: string, + ): CameraConfig | null { + const cameraConfig = cameras.get(cameraID); + if (!cameraConfig || cameraConfig.frigate.camera_name == CAMERA_BIRDSEYE) { + return null; + } + return cameraConfig; + } + + protected _splitSubLabels(input: string): string[] { + // A note on Frigate sub_labels: As of Frigate v0.12 sub_labels is a string + // (not an array) per event, but may contain comma-separated values (e.g. + // double-take (https://github.com/jakowenko/double-take) identifying two + // people in the same photo). When we search for multiple sub_labels, the + // integration will comma-join them together, then the Frigate backend will + // do the magic to match exactly or against a comma-separated part. + return input.split(',').map((s) => s.trim()); + } + + public async getMediaMetadata( + hass: HomeAssistant, + cameras: CameraConfigs, + query: MediaMetadataQuery, + engineOptions?: EngineOptions, + ): Promise { + const output: MediaMetadataQueryResultsMap = new Map(); + if ((engineOptions?.useCache ?? true) && this._requestCache.has(query)) { + const cachedResult = ( + this._requestCache.get(query) + ); + if (cachedResult) { + output.set(query, cachedResult as MediaMetadataQueryResults); + return output; + } + } + + const what: Set = new Set(); + const where: Set = new Set(); + const days: Set = new Set(); + const tags: Set = new Set(); + + const instances = this._buildInstanceToCameraIDMapFromQuery( + cameras, + query.cameraIDs, + ); + + const processEventSummary = async ( + instanceID: string, + cameraIDs: Set, + ): Promise => { + const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs); + for (const entry of await getEventSummary(hass, instanceID)) { + if (!cameraNames.has(entry.camera)) { + // If this entry applies to a camera that *is* in this Frigate + // instance, but is *not* a configured camera in the card, skip it. + continue; + } + if (entry.label) { + what.add(entry.label); + } + if (entry.zones.length) { + entry.zones.forEach(where.add, where); + } + if (entry.day) { + days.add(entry.day); + } + if (entry.sub_label) { + this._splitSubLabels(entry.sub_label).forEach(tags.add, tags); + } + } + }; + + const processRecordings = async (cameraIDs: Set): Promise => { + const recordings = await this.getRecordings( + hass, + cameras, + { + type: QueryType.Recording, + cameraIDs: cameraIDs, + }, + engineOptions, + ); + if (!recordings) { + return; + } + + for (const result of recordings.values()) { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { + continue; + } + + for (const recording of result.recordings) { + // Frigate recordings are always 1 hour long, i.e. never span a day. + days.add(formatDate(recording.startTime)); + } + } + }; + + await allPromises([...instances.entries()], ([instanceID, cameraIDs]) => + (async () => { + await Promise.all([ + processEventSummary(instanceID, cameraIDs), + processRecordings(cameraIDs), + ]); + })(), + ); + + const result: MediaMetadataQueryResults = { + type: QueryResultsType.MediaMetadata, + engine: Engine.Frigate, + metadata: { + ...(what.size && { what: what }), + ...(where.size && { where: where }), + ...(days.size && { days: days }), + ...(tags.size && { tags: tags }), + }, + expiry: add(new Date(), { seconds: MEDIA_METADATA_REQUEST_CACHE_AGE_SECONDS }), + cached: false, + }; + + if (engineOptions?.useCache ?? true) { + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + } + output.set(query, result); + return output; + } + + /** + * Garbage collect recording segments that no longer feature in the recordings + * returned by the Frigate backend. + */ + protected async _garbageCollectSegments( + hass: HomeAssistant, + cameras: CameraConfigs, + ): Promise { + const cameraIDs = this._recordingSegmentsCache.getCameraIDs(); + const recordingQuery: RecordingQuery = { + cameraIDs: new Set(cameraIDs), + type: QueryType.Recording, + }; + + const countSegments = () => + sum( + cameraIDs.map( + (cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0, + ), + ); + const segmentsStart = countSegments(); + + // Performance: _recordingSegments is potentially very large (e.g. 10K - 1M + // items) and each item must be examined, so care required here to stick to + // nothing worse than O(n) performance. + const getHourID = (cameraID: string, startTime: Date): string => { + return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`; + }; + + const results = await this.getRecordings(hass, cameras, recordingQuery); + if (!results) { + return; + } + + for (const [query, result] of results) { + if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) { + continue; + } + + const goodHours: Set = new Set(); + for (const recording of result.recordings) { + goodHours.add(getHourID(recording.cameraID, recording.startTime)); + } + + // Frigate recordings are always executed individually, so there'll only + // be a single results. + const cameraID = Array.from(query.cameraIDs)[0]; + this._recordingSegmentsCache.expireMatches( + cameraID, + (segment: RecordingSegment) => { + const hourID = getHourID(cameraID, fromUnixTime(segment.start_time)); + // ~O(1) lookup time for a JS set. + return !goodHours.has(hourID); + }, + ); + } + + log( + this._cardWideConfig, + 'Frigate Card recording segment garbage collection: ' + + `Released ${segmentsStart - countSegments()} segment(s)`, + ); + } + + /** + * Get the number of seconds to seek into a video stream consisting of the + * provided segments to reach the target time provided. + * @param startTime The earliest allowable time to seek from. + * @param targetTime Target time. + * @param segments An array of segments dataset items. Must be sorted from oldest to youngest. + * @returns + */ + protected _getSeekTimeInSegments( + startTime: Date, + targetTime: Date, + segments: RecordingSegment[], + ): number | null { + if (!segments.length) { + return null; + } + let seekMilliseconds = 0; + + // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 + for (const segment of segments) { + const segmentStart = fromUnixTime(segment.start_time); + if (segmentStart > targetTime) { + break; + } + const segmentEnd = fromUnixTime(segment.end_time); + const start = segmentStart < startTime ? startTime : segmentStart; + const end = segmentEnd > targetTime ? targetTime : segmentEnd; + seekMilliseconds += end.getTime() - start.getTime(); + } + return seekMilliseconds / 1000; + } + + public getCameraCapabilities( + cameraConfig: CameraConfig, + ): CameraManagerCameraCapabilities { + const isBirdseye = cameraConfig.frigate.camera_name === CAMERA_BIRDSEYE; + return { + canFavoriteEvents: !isBirdseye, + canFavoriteRecordings: !isBirdseye, + canSeek: true, + supportsClips: !isBirdseye, + supportsSnapshots: !isBirdseye, + supportsRecordings: !isBirdseye, + supportsTimeline: !isBirdseye, + }; + } + + public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities { + return { + canFavorite: ViewMediaClassifier.isEvent(media), + canDownload: true, + }; + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata { + const metadata = super.getCameraMetadata(hass, cameraConfig); + return { + title: + cameraConfig.title ?? + getEntityTitle(hass, cameraConfig.camera_entity) ?? + getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? + prettifyTitle(cameraConfig.frigate?.camera_name) ?? + cameraConfig.id ?? + '', + icon: metadata.icon, + engineLogo: frigateLogo, + }; + } + + public getCameraEndpoints( + cameraConfig: CameraConfig, + context?: CameraEndpointsContext, + ): CameraEndpoints | null { + const getUIEndpoint = (): CameraEndpoint | null => { + if (!cameraConfig.frigate.url) { + return null; + } + if (!cameraConfig.frigate.camera_name) { + return { endpoint: cameraConfig.frigate.url }; + } + + const cameraURL = + `${cameraConfig.frigate.url}/cameras/` + cameraConfig.frigate.camera_name; + + if (context?.view === 'live') { + return { endpoint: cameraURL }; + } + + const eventsURL = + `${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name; + const recordingsURL = + `${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name; + + // If media is available, use it since it may result in a more precisely + // correct URL. + switch (context?.media?.getMediaType()) { + case 'clip': + case 'snapshot': + return { endpoint: eventsURL }; + case 'recording': + const startTime = context.media.getStartTime(); + if (startTime) { + return { endpoint: recordingsURL + format(startTime, 'yyyy-MM-dd/HH') }; + } + } + + // Otherwise, fall back to just using the view if we have that. + switch (context?.view) { + case 'clip': + case 'clips': + case 'snapshots': + case 'snapshot': + return { endpoint: eventsURL }; + case 'recording': + case 'recordings': + return { endpoint: recordingsURL }; + } + + return { + endpoint: cameraURL, + }; + }; + + const getGo2RTC = (): CameraEndpoint | null => { + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + // go2rtc is exposed by the integration under the (slightly + // misleading) 'mse' path, even though that path can serve all go2rtc + // modes. + `/mse/api/ws?src=${ + cameraConfig.go2rtc?.stream ?? cameraConfig.frigate.camera_name + }`, + sign: true, + }; + }; + + const getJSMPEG = (): CameraEndpoint | null => { + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/jsmpeg/${cameraConfig.frigate.camera_name}`, + sign: true, + }; + }; + + const getWebRTCCard = (): CameraEndpoint | null => { + // By default use the frigate camera name which is the default recommended + // setup as per: + // https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/ + // + // The user may override this in their webrtc_card configuration. + const endpoint = cameraConfig.frigate.camera_name + ? cameraConfig.frigate.camera_name + : null; + return endpoint ? { endpoint: endpoint } : null; + }; + + const ui = getUIEndpoint(); + const go2rtc = getGo2RTC(); + const jsmpeg = getJSMPEG(); + const webrtcCard = getWebRTCCard(); + + return { + ...(ui && { ui: ui }), + ...(go2rtc && { go2rtc: go2rtc }), + ...(jsmpeg && { jsmpeg: jsmpeg }), + ...(webrtcCard && { webrtcCard: webrtcCard }), + }; + } +} diff --git a/src/camera-manager/frigate/icon.ts b/src/camera-manager/frigate/icon.ts new file mode 100644 index 00000000..3e075f2e --- /dev/null +++ b/src/camera-manager/frigate/icon.ts @@ -0,0 +1,20 @@ +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'; diff --git a/src/camera-manager/frigate/media-classifier.ts b/src/camera-manager/frigate/media-classifier.ts new file mode 100644 index 00000000..442d6b0d --- /dev/null +++ b/src/camera-manager/frigate/media-classifier.ts @@ -0,0 +1,18 @@ +import { ViewMedia } from '../../view/media'; +import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media'; + +export class FrigateViewMediaClassifier { + public static isFrigateMedia( + media: ViewMedia, + ): media is FrigateEventViewMedia | FrigateRecordingViewMedia { + return this.isFrigateEvent(media) || this.isFrigateRecording(media); + } + public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia { + return media instanceof FrigateEventViewMedia; + } + public static isFrigateRecording( + media: ViewMedia, + ): media is FrigateRecordingViewMedia { + return media instanceof FrigateRecordingViewMedia; + } +} diff --git a/src/camera-manager/frigate/media.ts b/src/camera-manager/frigate/media.ts new file mode 100644 index 00000000..f096f40f --- /dev/null +++ b/src/camera-manager/frigate/media.ts @@ -0,0 +1,206 @@ +import fromUnixTime from 'date-fns/fromUnixTime'; +import isEqual from 'lodash-es/isEqual'; +import { CameraConfig } from '../../types'; +import { + ViewMedia, + EventViewMedia, + RecordingViewMedia, + ViewMediaType, + VideoContentType, +} from '../../view/media'; +import { FrigateEvent, FrigateRecording } from './types'; +import { + getEventMediaContentID, + getEventThumbnailURL, + getEventTitle, + getRecordingID, + getRecordingMediaContentID, + getRecordingTitle, +} from './util'; + +export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia { + protected _event: FrigateEvent; + protected _contentID: string; + protected _thumbnail: string; + protected _subLabels: string[] | null; + + constructor( + mediaType: ViewMediaType, + cameraID: string, + event: FrigateEvent, + contentID: string, + thumbnail: string, + // See 'A note on Frigate sub_labels' in engine-frigate.ts for more + // details about why sub-labels are treated specially. By taking in + // subLabels as an array here, we can keep a single place that splits + // sublabels (`_splitSubLabels` in engine-frigate.ts). + subLabels?: string[], + ) { + super(mediaType, cameraID); + this._event = event; + this._contentID = contentID; + this._thumbnail = thumbnail; + this._subLabels = subLabels ?? null; + } + + public getStartTime(): Date { + return fromUnixTime(this._event.start_time); + } + public getEndTime(): Date | null { + return this._event.end_time ? fromUnixTime(this._event.end_time) : null; + } + public inProgress(): boolean | null { + // In Frigate, events/recordings always have end times unless they are in + // progress. + return !this.getEndTime(); + } + public getVideoContentType(): VideoContentType | null { + return VideoContentType.HLS; + } + public getID(): string { + return this._event.id; + } + public getContentID(): string { + return this._contentID; + } + public getTitle(): string | null { + return getEventTitle(this._event); + } + public getThumbnail(): string | null { + return this._thumbnail; + } + public isFavorite(): boolean | null { + return this._event.retain_indefinitely ?? null; + } + public setFavorite(favorite: boolean): void { + this._event.retain_indefinitely = favorite; + } + public getWhat(): string[] | null { + return [this._event.label]; + } + public getWhere(): string[] | null { + const zones = this._event.zones; + return zones.length ? zones : null; + } + public getScore(): number | null { + return this._event.top_score; + } + public getTags(): string[] | null { + return this._subLabels; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isGroupableWith(that: EventViewMedia): boolean { + return ( + this.getMediaType() === that.getMediaType() && + isEqual(this.getWhere(), that.getWhere()) && + isEqual(this.getWhat(), that.getWhat()) + ); + } +} + +export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia { + protected _recording: FrigateRecording; + protected _id: string; + protected _contentID: string; + protected _title: string; + + constructor( + mediaType: ViewMediaType, + cameraID: string, + recording: FrigateRecording, + id: string, + contentID: string, + title: string, + ) { + super(mediaType, cameraID); + this._recording = recording; + this._id = id; + this._contentID = contentID; + this._title = title; + } + + public getID(): string { + return this._id; + } + public getStartTime(): Date { + return this._recording.startTime; + } + public getEndTime(): Date { + return this._recording.endTime; + } + public inProgress(): boolean | null { + // In Frigate, events/recordings always have end times unless they are in + // progress. + return !this.getEndTime(); + } + public getVideoContentType(): VideoContentType | null { + return VideoContentType.HLS; + } + public getContentID(): string | null { + return this._contentID; + } + public getTitle(): string | null { + return this._title; + } + public getEventCount(): number { + return this._recording.events; + } +} + +export class FrigateViewMediaFactory { + static createEventViewMedia( + mediaType: 'clip' | 'snapshot', + cameraID: string, + cameraConfig: CameraConfig, + event: FrigateEvent, + subLabels?: string[], + ): FrigateEventViewMedia | null { + if ( + (mediaType === 'clip' && !event.has_clip) || + (mediaType === 'snapshot' && !event.has_snapshot) || + !cameraConfig.frigate.client_id || + !cameraConfig.frigate.camera_name + ) { + return null; + } + + return new FrigateEventViewMedia( + mediaType, + cameraID, + event, + getEventMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + event, + mediaType === 'clip' ? 'clips' : 'snapshots', + ), + getEventThumbnailURL(cameraConfig.frigate.client_id, event), + subLabels, + ); + } + + static createRecordingViewMedia( + cameraID: string, + recording: FrigateRecording, + cameraConfig: CameraConfig, + cameraTitle: string, + ): FrigateRecordingViewMedia | null { + if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) { + return null; + } + + return new FrigateRecordingViewMedia( + 'recording', + cameraID, + recording, + getRecordingID(cameraConfig, recording), + getRecordingMediaContentID( + cameraConfig.frigate.client_id, + cameraConfig.frigate.camera_name, + recording, + ), + getRecordingTitle(cameraTitle, recording), + ); + } +} diff --git a/src/camera-manager/frigate/requests.ts b/src/camera-manager/frigate/requests.ts new file mode 100644 index 00000000..3cf751ba --- /dev/null +++ b/src/camera-manager/frigate/requests.ts @@ -0,0 +1,153 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { localize } from '../../localize/localize'; +import { FrigateCardError } from '../../types'; +import { homeAssistantWSRequest } from '../../utils/ha'; +import { RecordingSegment } from '../types'; +import { + EventSummary, + eventSummarySchema, + FrigateEvent, + frigateEventsSchema, + recordingSegmentsSchema, + RecordingSummary, + recordingSummarySchema, + RetainResult, + retainResultSchema, +} from './types'; + +/** + * Get the recordings summary. May throw. + * @param hass The Home Assistant object. + * @param clientID The Frigate clientID. + * @param camera_name The Frigate camera name. + * @returns A RecordingSummary object. + */ +export const getRecordingsSummary = async ( + hass: HomeAssistant, + clientID: string, + camera_name: string, +): Promise => { + return (await homeAssistantWSRequest( + hass, + recordingSummarySchema, + { + type: 'frigate/recordings/summary', + instance_id: clientID, + camera: camera_name, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + true, + // See: https://github.com/colinhacks/zod/pull/1752 + )) as RecordingSummary; +}; + +export interface NativeFrigateRecordingSegmentsQuery { + instance_id: string; + camera: string; + after: number; + before: number; +} + +/** + * Get the recording segments. May throw. + * @param hass The Home Assistant object. + * @param params The recording segment query parameters. + * @returns A RecordingSegments object. + */ +export const getRecordingSegments = async ( + hass: HomeAssistant, + params: NativeFrigateRecordingSegmentsQuery, +): Promise => { + return await homeAssistantWSRequest( + hass, + recordingSegmentsSchema, + { + type: 'frigate/recordings/get', + ...params, + }, + true, + ); +}; + +/** + * Request that Frigate retain an event. May throw. + * @param hass The HomeAssistant object. + * @param clientID The Frigate clientID. + * @param eventID The event ID to retain. + * @param retain `true` to retain or `false` to unretain. + */ +export async function retainEvent( + hass: HomeAssistant, + clientID: string, + eventID: string, + retain: boolean, +): Promise { + const retainRequest = { + type: 'frigate/event/retain', + instance_id: clientID, + 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, + }); + } +} + +export interface NativeFrigateEventQuery { + instance_id?: string; + cameras?: string[]; + labels?: string[]; + zones?: string[]; + after?: number; + before?: number; + limit?: number; + has_clip?: boolean; + has_snapshot?: boolean; + favorites?: boolean; +} + +/** + * Get events over websocket. May throw. + * @param hass The Home Assistant object. + * @param params The events search parameters. + * @returns An array of 'FrigateEvent's. + */ +export const getEvents = async ( + hass: HomeAssistant, + params?: NativeFrigateEventQuery, +): Promise => { + return await homeAssistantWSRequest( + hass, + frigateEventsSchema, + { + type: 'frigate/events/get', + ...params, + }, + true, + ); +}; + +export const getEventSummary = async ( + hass: HomeAssistant, + clientID: string, +): Promise => { + return await homeAssistantWSRequest( + hass, + eventSummarySchema, + { + type: 'frigate/events/summary', + instance_id: clientID, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + true, + ); +}; diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts new file mode 100644 index 00000000..ef18041c --- /dev/null +++ b/src/camera-manager/frigate/types.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import { dayToDate } from '../../utils/basic'; +import { + Engine, + EventQueryResults, + RecordingQueryResults, + RecordingSegmentsQueryResults, +} from '../types'; + +const dayStringToDate = (arg: unknown): Date | unknown => { + return typeof arg === 'string' ? dayToDate(arg) : arg; +}; + +const eventSchema = 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(), + sub_label: z.string().nullable(), + start_time: z.number(), + top_score: z.number(), + zones: z.string().array(), + retain_indefinitely: z.boolean().optional(), +}); +export const frigateEventsSchema = eventSchema.array(); + +export type FrigateEvent = z.infer; + +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), +}); + +export const recordingSummarySchema = z + .object({ + day: z.preprocess(dayStringToDate, 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(), +}); +export const recordingSegmentsSchema = recordingSegmentSchema.array(); + +export const retainResultSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); +export type RetainResult = z.infer; + +export interface FrigateRecording { + cameraID: string; + startTime: Date; + endTime: Date; + events: number; +} + +export const eventSummarySchema = z + .object({ + camera: z.string(), + // Days in RFC3339 format. + day: z.string(), + label: z.string(), + sub_label: z.string().nullable(), + zones: z.string().array(), + }) + .array(); +export type EventSummary = z.infer; + +// ============================== +// Frigate concrete query results +// ============================== + +export interface FrigateEventQueryResults extends EventQueryResults { + engine: Engine.Frigate; + instanceID: string; + events: FrigateEvent[]; +} + +export interface FrigateRecordingQueryResults extends RecordingQueryResults { + engine: Engine.Frigate; + instanceID: string; + recordings: FrigateRecording[]; +} + +export interface FrigateRecordingSegmentsQueryResults + extends RecordingSegmentsQueryResults { + engine: Engine.Frigate; + instanceID: string; +} diff --git a/src/camera-manager/frigate/util.ts b/src/camera-manager/frigate/util.ts new file mode 100644 index 00000000..0be47429 --- /dev/null +++ b/src/camera-manager/frigate/util.ts @@ -0,0 +1,97 @@ +import utcToZonedTime from 'date-fns-tz/utcToZonedTime'; +import { CameraConfig, ClipsOrSnapshots } from '../../types'; +import { formatDateAndTime, prettifyTitle } from '../../utils/basic'; +import { FrigateEvent, FrigateRecording } from './types'; + +/** + * Given an event generate a title. + * @param event + */ +export const getEventTitle = (event: FrigateEvent): string => { + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const durationSeconds = Math.round( + event.end_time + ? event.end_time - event.start_time + : Date.now() / 1000 - event.start_time, + ); + return `${formatDateAndTime( + utcToZonedTime(event.start_time * 1000, localTimezone), + )} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round( + event.top_score * 100, + )}%]`; +}; + +export const getRecordingTitle = ( + cameraTitle: string, + recording: FrigateRecording, +): string => { + return `${cameraTitle} ${formatDateAndTime(recording.startTime)}`; +}; + +/** + * Get a thumbnail URL for an event. + * @param clientId The Frigate client id. + * @param event The event. + * @returns A string URL. + */ +export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => { + return `/api/frigate/${clientId}/thumbnail/${event.id}`; +}; + +/** + * Get a media content ID for an event. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param event The Frigate event. + * @param mediaType The media type required. + * @returns A string media content id. + */ +export const getEventMediaContentID = ( + clientId: string, + cameraName: string, + event: FrigateEvent, + mediaType: ClipsOrSnapshots, +): string => { + return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`; +}; + +/** + * Generate a recording identifier. + * @param clientId The Frigate client id. + * @param cameraName The Frigate camera name. + * @param recording The Frigate recording. + * @returns A recording identifier. + */ +export const getRecordingMediaContentID = ( + clientId: string, + cameraName: string, + recording: FrigateRecording, +): string => { + return [ + 'media-source://frigate', + clientId, + 'recordings', + cameraName, + `${recording.startTime.getFullYear()}-${String( + recording.startTime.getMonth() + 1, + ).padStart(2, '0')}-${String( + String(recording.startTime.getDate()).padStart(2, '0'), + )}`, + String(recording.startTime.getHours()).padStart(2, '0'), + ].join('/'); +}; + +/** + * Get a recording ID for internal de-duping. + */ +export const getRecordingID = ( + cameraConfig: CameraConfig, + recording: FrigateRecording, +): string => { + // ID name is derived from the real camera name (not CameraID) since the + // recordings for the same camera across multiple zones will be the same and + // can be dedup'd from this id. + return `${cameraConfig.frigate?.client_id ?? ''}/${ + cameraConfig.frigate.camera_name ?? '' + }/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`; +}; diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts new file mode 100644 index 00000000..9e9207ba --- /dev/null +++ b/src/camera-manager/generic/engine-generic.ts @@ -0,0 +1,198 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig, ExtendedHomeAssistant } from '../../types'; +import { ViewMedia } from '../../view/media'; +import { + CameraManagerCameraMetadata, + CameraManagerMediaCapabilities, + DataQuery, + EventQuery, + EventQueryResultsMap, + PartialEventQuery, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + RecordingQueryResultsMap, + RecordingSegmentsQuery, + RecordingSegmentsQueryResultsMap, + CameraEndpointsContext, + CameraConfigs, + RecordingQuery, + QueryReturnType, + CameraManagerCameraCapabilities, + Engine, + CameraEndpoints, + MediaMetadataQuery, + MediaMetadataQueryResultsMap, + EngineOptions, + CameraEndpoint, +} from '../types'; +import { getEntityIcon, getEntityTitle } from '../../utils/ha'; +import { EntityRegistryManager } from '../../utils/ha/entity-registry'; +import { CameraManagerEngine } from '../engine'; + +export class GenericCameraManagerEngine implements CameraManagerEngine { + public getEngineType(): Engine { + return Engine.Generic; + } + + public async initializeCamera( + _hass: HomeAssistant, + _entityRegistryManager: EntityRegistryManager, + cameraConfig: CameraConfig, + ): Promise { + return cameraConfig; + } + + public generateDefaultEventQuery( + _cameras: CameraConfigs, + _cameraIDs: Set, + _query: PartialEventQuery, + ): EventQuery[] | null { + return null; + } + + public generateDefaultRecordingQuery( + _cameras: CameraConfigs, + _cameraIDs: Set, + _query: PartialRecordingQuery, + ): RecordingQuery[] | null { + return null; + } + + public generateDefaultRecordingSegmentsQuery( + _cameras: CameraConfigs, + _cameraIDs: Set, + _query: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null { + return null; + } + + public async getEvents( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: EventQuery, + _engineOptions?: EngineOptions, + ): Promise { + return null; + } + + public async getRecordings( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: RecordingQuery, + _engineOptions?: EngineOptions, + ): Promise { + return null; + } + + public async getRecordingSegments( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: RecordingSegmentsQuery, + _engineOptions?: EngineOptions, + ): Promise { + return null; + } + + public generateMediaFromEvents( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: EventQuery, + _results: QueryReturnType, + ): ViewMedia[] | null { + return null; + } + + public generateMediaFromRecordings( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: RecordingQuery, + _results: QueryReturnType, + ): ViewMedia[] | null { + return null; + } + + public async getMediaDownloadPath( + _hass: ExtendedHomeAssistant, + _cameraConfig: CameraConfig, + _media: ViewMedia, + ): Promise { + return null; + } + + public async favoriteMedia( + _hass: HomeAssistant, + _cameraConfig: CameraConfig, + _media: ViewMedia, + _favorite: boolean, + ): Promise { + return; + } + + public getQueryResultMaxAge(_query: DataQuery): number | null { + return null; + } + + public async getMediaSeekTime( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _media: ViewMedia, + _target: Date, + _engineOptions?: EngineOptions, + ): Promise { + return null; + } + + public async getMediaMetadata( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: MediaMetadataQuery, + _engineOptions?: EngineOptions, + ): Promise { + return null; + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata { + return { + title: + cameraConfig.title ?? + getEntityTitle(hass, cameraConfig.camera_entity) ?? + getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? + cameraConfig.id ?? + '', + icon: + cameraConfig?.icon ?? + getEntityIcon(hass, cameraConfig.camera_entity) ?? + 'mdi:video', + }; + } + + public getCameraCapabilities( + _cameraConfig: CameraConfig, + ): CameraManagerCameraCapabilities | null { + return { + canFavoriteEvents: false, + canFavoriteRecordings: false, + canSeek: false, + supportsClips: false, + supportsRecordings: false, + supportsSnapshots: false, + supportsTimeline: false, + }; + } + + public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null { + return null; + } + + public getCameraEndpoints( + _cameraConfig: CameraConfig, + _context?: CameraEndpointsContext, + ): CameraEndpoints | null { + return null; + } +} diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts new file mode 100644 index 00000000..b686082f --- /dev/null +++ b/src/camera-manager/manager.ts @@ -0,0 +1,760 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { + CameraConfig, + CamerasConfig, + CardWideConfig, + ExtendedHomeAssistant, +} from '../types.js'; +import { allPromises, arrayify, setify } from '../utils/basic.js'; +import { + CameraManagerCameraCapabilities, + CameraManagerCameraMetadata, + CameraManagerCapabilities, + CameraManagerMediaCapabilities, + CameraEndpointsContext, + DataQuery, + EventQuery, + EventQueryResults, + EventQueryResultsMap, + MediaMetadata, + MediaQuery, + PartialDataQuery, + PartialEventQuery, + PartialQueryConcreteType, + PartialRecordingQuery, + PartialRecordingSegmentsQuery, + QueryResults, + QueryResultsType, + QueryReturnType, + QueryType, + RecordingQuery, + RecordingQueryResults, + RecordingQueryResultsMap, + RecordingSegmentsQuery, + RecordingSegmentsQueryResults, + RecordingSegmentsQueryResultsMap, + ResultsMap, + CameraEndpoints, + Engine, + MediaMetadataQuery, + MediaMetadataQueryResults, + EngineOptions, + CameraEndpoint, +} from './types.js'; +import { CameraManagerEngineFactory } from './engine-factory.js'; +import { ViewMedia } from '../view/media.js'; +import { CameraManagerEngine } from './engine.js'; +import sum from 'lodash-es/sum'; +import add from 'date-fns/add'; +import { log } from '../utils/debug.js'; +import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; +import { getCameraID } from '../utils/camera.js'; +import { localize } from '../localize/localize.js'; +import { CameraInitializationError } from './error.js'; +import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js'; +import cloneDeep from 'lodash-es/cloneDeep'; +import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js'; +import { sortMedia } from './util.js'; + +class QueryClassifier { + public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery { + return query.type === QueryType.Event; + } + public static isRecordingQuery( + query: DataQuery | PartialDataQuery, + ): query is RecordingQuery { + return query.type === QueryType.Recording; + } + public static isRecordingSegmentsQuery( + query: DataQuery | PartialDataQuery, + ): query is RecordingSegmentsQuery { + return query.type === QueryType.RecordingSegments; + } + public static isMediaMetadataQuery( + query: DataQuery | PartialDataQuery, + ): query is MediaMetadataQuery { + return query.type === QueryType.MediaMetadata; + } +} + +class QueryResultClassifier { + public static isEventQueryResult( + queryResults: QueryResults, + ): queryResults is EventQueryResults { + return queryResults.type === QueryResultsType.Event; + } + public static isRecordingQuery( + queryResults: QueryResults, + ): queryResults is RecordingQueryResults { + return queryResults.type === QueryResultsType.Recording; + } + public static isRecordingSegmentsQuery( + queryResults: QueryResults, + ): queryResults is RecordingSegmentsQueryResults { + return queryResults.type === QueryResultsType.RecordingSegments; + } + public static isMediaMetadataQuery( + queryResults: QueryResults, + ): queryResults is MediaMetadataQueryResults { + return queryResults.type === QueryResultsType.MediaMetadata; + } +} + +export interface ExtendedMediaQueryResult { + queries: T[]; + results: ViewMedia[]; +} + +interface InitializedCamera { + inputConfig: CameraConfig; + initializedConfig: CameraConfig; + engine: CameraManagerEngine; +} + +export class CameraManager { + protected _engineFactory: CameraManagerEngineFactory; + protected _cardWideConfig?: CardWideConfig; + protected _store: CameraManagerStore; + + constructor( + engineFactory: CameraManagerEngineFactory, + cardWideConfig?: CardWideConfig, + ) { + this._engineFactory = engineFactory; + this._cardWideConfig = cardWideConfig; + this._store = new CameraManagerStore(); + } + + protected async _getEnginesForCameras( + hass: HomeAssistant, + camerasConfig: CamerasConfig, + ): Promise> { + const output: Map = new Map(); + const engines: Map = new Map(); + + const getEngineTypes = async (configs: CameraConfig[]) => { + return await allPromises(configs, (config) => + this._engineFactory.getEngineForCamera(hass, config), + ); + }; + + const engineTypes = await getEngineTypes(camerasConfig); + for (const [index, cameraConfig] of camerasConfig.entries()) { + const engineType = engineTypes[index]; + const engine = engineType + ? engines.get(engineType) ?? await this._engineFactory.createEngine(engineType) + : null; + if (!engine || !engineType) { + throw new CameraInitializationError( + localize('error.no_camera_engine'), + cameraConfig, + ); + } + engines.set(engineType, engine); + output.set(cameraConfig, engine); + } + return output; + } + + protected async _initializeCamera( + hass: HomeAssistant, + engine: CameraManagerEngine, + entityRegistryManager: EntityRegistryManager, + inputCameraConfig: CameraConfig, + ): Promise { + const initializedConfig = await engine.initializeCamera( + hass, + entityRegistryManager, + // Camera initialization may modify the configuration. Keep the original + // for display in error messages to avoid user confusion. + cloneDeep(inputCameraConfig), + ); + + return { + inputConfig: inputCameraConfig, + initializedConfig: initializedConfig, + engine: engine, + }; + } + + public async initializeCameras( + hass: HomeAssistant, + entityRegistryManager: EntityRegistryManager, + camerasConfig: CamerasConfig, + ): Promise { + const initializationStartTime = new Date(); + + const hasAutoTriggers = (config: CameraConfig): boolean => { + return config.triggers.motion || config.triggers.occupancy; + }; + + if ( + // If any camera requires automatic trigger detection ... + camerasConfig.some((config) => hasAutoTriggers(config)) + ) { + // ... then we need to populate the entity cache by fetching all entities + // from Home Assistant. Do this once upfront, to avoid each camera doing + // it. + await entityRegistryManager.fetchEntityList(hass); + } + + // Engines are created sequentially, to avoid duplicate creation of the same + // engine. See: https://github.com/dermotduffy/frigate-hass-card/issues/941 + const engineByConfig = await this._getEnginesForCameras(hass, camerasConfig); + + // Configuration is initialized in parallel. + const results = await allPromises( + engineByConfig.entries(), + async ([cameraConfig, engine]) => + await this._initializeCamera(hass, engine, entityRegistryManager, cameraConfig), + ); + + // Do the additions based off the result-order, to ensure the map order is + // preserved. + results.forEach((result) => { + const id = getCameraID(result.initializedConfig); + + if (!id) { + throw new CameraInitializationError( + localize('error.no_camera_id'), + result.inputConfig, + ); + } + + if (this._store.hasCameraID(id)) { + throw new CameraInitializationError( + localize('error.duplicate_camera_id'), + result.inputConfig, + ); + } + + this._store.addCamera(id, result.initializedConfig, result.engine); + }); + + if (!this._store.getVisibleCameraCount()) { + throw new CameraInitializationError(localize('error.no_visible_cameras')); + } + + log( + this._cardWideConfig, + 'Frigate Card CameraManager initialized (Cameras: ', + this._store.getCameras(), + `, Duration: ${ + (new Date().getTime() - initializationStartTime.getTime()) / 1000 + }s,`, + ')', + ); + } + + public isInitialized(): boolean { + return this._store.getCameraCount() > 0; + } + + public getStore(): CameraManagerReadOnlyConfigStore { + return this._store; + } + + public generateDefaultEventQueries( + cameraIDs: string | Set, + partialQuery?: PartialEventQuery, + ): EventQuery[] | null { + return this._generateDefaultQueries(cameraIDs, { + type: QueryType.Event, + ...partialQuery, + }); + } + + public generateDefaultRecordingQueries( + cameraIDs: string | Set, + partialQuery?: PartialRecordingQuery, + ): RecordingQuery[] | null { + return this._generateDefaultQueries(cameraIDs, { + type: QueryType.Recording, + ...partialQuery, + }); + } + + public generateDefaultRecordingSegmentsQueries( + cameraIDs: string | Set, + partialQuery?: PartialRecordingSegmentsQuery, + ): RecordingSegmentsQuery[] | null { + return this._generateDefaultQueries(cameraIDs, { + type: QueryType.RecordingSegments, + ...partialQuery, + }); + } + + public async getMediaMetadata(hass: HomeAssistant): Promise { + const tags: Set = new Set(); + const what: Set = new Set(); + const where: Set = new Set(); + const days: Set = new Set(); + + const query: MediaMetadataQuery = { + type: QueryType.MediaMetadata, + cameraIDs: this._store.getCameraIDs(), + }; + + const results = await this._handleQuery(hass, query); + + for (const result of results?.values() ?? []) { + if (result.metadata.tags) { + result.metadata.tags.forEach(tags.add, tags); + } + if (result.metadata.what) { + result.metadata.what.forEach(what.add, what); + } + if (result.metadata.where) { + result.metadata.where.forEach(where.add, where); + } + if (result.metadata.days) { + result.metadata.days.forEach(days.add, days); + } + } + + if (!what.size && !where.size && !days.size) { + return null; + } + return { + ...(tags.size && { tags: tags }), + ...(what.size && { what: what }), + ...(where.size && { where: where }), + ...(days.size && { days: days }), + }; + } + + protected _generateDefaultQueries( + cameraIDs: string | Set, + partialQuery: PQT, + ): PartialQueryConcreteType[] | null { + const concreteQueries: PartialQueryConcreteType[] = []; + const _cameraIDs = setify(cameraIDs); + const engines = this._store.getEnginesForCameraIDs(_cameraIDs); + + if (!engines) { + return null; + } + + for (const [engine, cameraIDs] of engines) { + let queries: DataQuery[] | null = null; + if (QueryClassifier.isEventQuery(partialQuery)) { + queries = engine.generateDefaultEventQuery( + this._store.getVisibleCameras(), + cameraIDs, + partialQuery, + ); + } else if (QueryClassifier.isRecordingQuery(partialQuery)) { + queries = engine.generateDefaultRecordingQuery( + this._store.getVisibleCameras(), + cameraIDs, + partialQuery, + ); + } else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) { + queries = engine.generateDefaultRecordingSegmentsQuery( + this._store.getVisibleCameras(), + cameraIDs, + partialQuery, + ); + } + + for (const query of queries ?? []) { + concreteQueries.push(query as PartialQueryConcreteType); + } + } + return concreteQueries.length ? concreteQueries : null; + } + + public async getEvents( + hass: HomeAssistant, + query: EventQuery | EventQuery[], + engineOptions?: EngineOptions, + ): Promise { + return await this._handleQuery(hass, query, engineOptions); + } + + public async getRecordings( + hass: HomeAssistant, + query: RecordingQuery | RecordingQuery[], + engineOptions?: EngineOptions, + ): Promise { + return await this._handleQuery(hass, query, engineOptions); + } + + public async getRecordingSegments( + hass: HomeAssistant, + query: RecordingSegmentsQuery | RecordingSegmentsQuery[], + engineOptions?: EngineOptions, + ): Promise { + return await this._handleQuery(hass, query, engineOptions); + } + + public async executeMediaQueries( + hass: HomeAssistant, + queries: T[], + engineOptions?: EngineOptions, + ): Promise { + return this._convertQueryResultsToMedia( + hass, + await this._handleQuery(hass, queries, engineOptions), + ); + } + + public async extendMediaQueries( + hass: HomeAssistant, + queries: T[], + results: ViewMedia[], + direction: 'earlier' | 'later', + engineOptions?: EngineOptions, + ): Promise | null> { + const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => { + let output: Date | null = null; + for (const result of results) { + const startTime = result.getStartTime(); + if ( + startTime && + (!output || + (want === 'earliest' && startTime < output) || + (want === 'latest' && startTime > output)) + ) { + output = startTime; + } + } + return output; + }; + + const chunkSize = + this._cardWideConfig?.performance?.features.media_chunk_size ?? + MEDIA_CHUNK_SIZE_DEFAULT; + + // The queries associated with the chunk to fetch. + const newChunkQueries: T[] = []; + + // The re-constituted combined query. + const extendedQueries: T[] = []; + + for (const query of queries) { + const newChunkQuery = { ...query }; + + if (direction === 'later') { + const latestResult = getTimeFromResults('latest'); + if (latestResult) { + newChunkQuery.start = latestResult; + } + } else if (direction === 'earlier') { + const earliestResult = getTimeFromResults('earliest'); + if (earliestResult) { + newChunkQuery.end = earliestResult; + } + } + newChunkQuery.limit = chunkSize; + + extendedQueries.push({ + ...query, + limit: (query.limit ?? 0) + chunkSize, + }); + newChunkQueries.push(newChunkQuery); + } + + const newChunkMedia = this._convertQueryResultsToMedia( + hass, + await this._handleQuery(hass, newChunkQueries, engineOptions), + ); + + if (!newChunkMedia.length) { + return null; + } + + const outputMedia = sortMedia(results.concat(newChunkMedia)); + + // If the media did not _ACTUALLY_ get longer, there is no new media despite + // the increased limit, so just return null. + if (outputMedia.length === results.length) { + return null; + } + + return { + queries: extendedQueries, + results: outputMedia, + }; + } + + public async getMediaDownloadPath( + hass: ExtendedHomeAssistant, + media: ViewMedia, + ): Promise { + const cameraConfig = this._store.getCameraConfigForMedia(media); + const engine = this._store.getEngineForMedia(media); + + if (!cameraConfig || !engine) { + return null; + } + return await engine.getMediaDownloadPath(hass, cameraConfig, media); + } + + public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null { + const engine = this._store.getEngineForMedia(media); + if (!engine) { + return null; + } + return engine.getMediaCapabilities(media); + } + + public async favoriteMedia( + hass: HomeAssistant, + media: ViewMedia, + favorite: boolean, + ): Promise { + const cameraConfig = this._store.getCameraConfigForMedia(media); + const engine = this._store.getEngineForMedia(media); + + if (!cameraConfig || !engine) { + return; + } + + const queryStartTime = new Date(); + await engine.favoriteMedia(hass, cameraConfig, media, favorite); + + log( + this._cardWideConfig, + 'Frigate Card CameraManager favorite request (', + `Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, + 'Media:', + media.getID(), + ', Favorite:', + favorite, + ')', + ); + } + + public areMediaQueriesResultsFresh( + queries: T[], + resultsTimestamp: Date, + ): boolean { + const now = new Date(); + + for (const query of queries) { + const engines = this._store.getEnginesForCameraIDs(query.cameraIDs); + for (const [engine, cameraIDs] of engines ?? []) { + const maxAgeSeconds = engine.getQueryResultMaxAge({ + ...query, + cameraIDs: cameraIDs, + }); + if ( + maxAgeSeconds !== null && + add(resultsTimestamp, { seconds: maxAgeSeconds }) < now + ) { + return false; + } + } + } + return true; + } + + public async getMediaSeekTime( + hass: HomeAssistant, + media: ViewMedia, + target: Date, + ): Promise { + const startTime = media.getStartTime(); + const endTime = media.getEndTime(); + const cameraConfig = this._store.getCameraConfigForMedia(media); + const engine = this._store.getEngineForMedia(media); + if ( + !cameraConfig || + !engine || + !startTime || + !endTime || + target < startTime || + target > endTime + ) { + return null; + } + + return await engine.getMediaSeekTime(hass, this._store.getCameras(), media, target); + } + + protected async _handleQuery( + hass: HomeAssistant, + query: QT | QT[], + engineOptions?: EngineOptions, + ): Promise>> { + const _queries = arrayify(query); + const results = new Map>(); + const queryStartTime = new Date(); + + const processEngineQuery = async ( + engine: CameraManagerEngine, + query?: QT, + ): Promise => { + if (!query) { + return; + } + + let engineResult: Map> | null = null; + if (QueryClassifier.isEventQuery(query)) { + engineResult = (await engine.getEvents( + hass, + this._store.getCameras(), + query, + engineOptions, + )) as Map> | null; + } else if (QueryClassifier.isRecordingQuery(query)) { + engineResult = (await engine.getRecordings( + hass, + this._store.getCameras(), + query, + engineOptions, + )) as Map> | null; + } else if (QueryClassifier.isRecordingSegmentsQuery(query)) { + engineResult = (await engine.getRecordingSegments( + hass, + this._store.getCameras(), + query, + engineOptions, + )) as Map> | null; + } else if (QueryClassifier.isMediaMetadataQuery(query)) { + engineResult = (await engine.getMediaMetadata( + hass, + this._store.getCameras(), + query, + engineOptions, + )) as Map> | null; + } + + engineResult?.forEach((value, key) => results.set(key, value)); + }; + + const processQuery = async (query: QT): Promise => { + const engines = this._store.getEnginesForCameraIDs(query.cameraIDs); + if (!engines) { + return; + } + await Promise.all( + Array.from(engines.keys()).map((engine) => + processEngineQuery(engine, { ...query, cameraIDs: engines.get(engine) }), + ), + ); + }; + + await Promise.all(_queries.map((query) => processQuery(query))); + + const cachedOutputQueries = sum( + Array.from(results.values()).map((result) => Number(result.cached ?? 0)), + ); + + log( + this._cardWideConfig, + 'Frigate Card CameraManager request [Input queries:', + _queries.length, + ', Cached output queries:', + cachedOutputQueries, + ', Total output queries:', + results.size, + ', Duration:', + `${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`, + ', Queries:', + _queries, + ', Results:', + results, + ']', + ); + return results; + } + + protected _convertQueryResultsToMedia( + hass: HomeAssistant, + results: ResultsMap, + ): ViewMedia[] { + const mediaArray: ViewMedia[] = []; + for (const [query, result] of results.entries()) { + const engine = this._store.getEngineOfType(result.engine); + + if (engine) { + let media: ViewMedia[] | null = null; + if ( + QueryClassifier.isEventQuery(query) && + QueryResultClassifier.isEventQueryResult(result) + ) { + media = engine.generateMediaFromEvents( + hass, + this._store.getCameras(), + query, + result, + ); + } else if ( + QueryClassifier.isRecordingQuery(query) && + QueryResultClassifier.isRecordingQuery(result) + ) { + media = engine.generateMediaFromRecordings( + hass, + this._store.getCameras(), + query, + result, + ); + } + if (media) { + mediaArray.push(...media); + } + } + } + return sortMedia(mediaArray); + } + + public getCameraEndpoints( + cameraID: string, + context?: CameraEndpointsContext, + ): CameraEndpoints | null { + const cameraConfig = this._store.getCameraConfig(cameraID); + const engine = this._store.getEngineForCameraID(cameraID); + if (!cameraConfig || !engine) { + return null; + } + return engine.getCameraEndpoints(cameraConfig, context); + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraID: string, + ): CameraManagerCameraMetadata | null { + const cameraConfig = this._store.getCameraConfig(cameraID); + const engine = this._store.getEngineForCameraID(cameraID); + if (!cameraConfig || !engine) { + return null; + } + return engine.getCameraMetadata(hass, cameraConfig); + } + + public getCameraCapabilities( + cameraID: string, + ): CameraManagerCameraCapabilities | null { + const cameraConfig = this._store.getCameraConfig(cameraID); + const engine = this._store.getEngineForCameraID(cameraID); + if (!cameraConfig || !engine) { + return null; + } + return engine.getCameraCapabilities(cameraConfig); + } + + public getAggregateCameraCapabilities( + cameraIDs?: Set, + ): CameraManagerCapabilities | null { + const perCameraCapabilities = [...(cameraIDs ?? this._store.getCameraIDs())].map( + (cameraID) => this.getCameraCapabilities(cameraID), + ); + + return { + canFavoriteEvents: perCameraCapabilities.some((cap) => cap?.canFavoriteEvents), + canFavoriteRecordings: perCameraCapabilities.some( + (cap) => cap?.canFavoriteRecordings, + ), + canSeek: perCameraCapabilities.some( + (cap) => cap?.canSeek, + ), + + supportsClips: perCameraCapabilities.some((cap) => cap?.supportsClips), + supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings), + supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots), + supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline), + }; + } +} diff --git a/src/camera-manager/motioneye/assets/motioneye-logo.svg b/src/camera-manager/motioneye/assets/motioneye-logo.svg new file mode 100644 index 00000000..28ba99bb --- /dev/null +++ b/src/camera-manager/motioneye/assets/motioneye-logo.svg @@ -0,0 +1,242 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts new file mode 100644 index 00000000..badabd70 --- /dev/null +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -0,0 +1,403 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CameraConfig } from '../../types'; +import { ViewMedia } from '../../view/media'; +import { + CameraConfigs, + CameraEndpoint, + CameraEndpoints, + CameraEndpointsContext, + CameraManagerCameraMetadata, + Engine, + EngineOptions, + EventQuery, + EventQueryResults, + EventQueryResultsMap, + MediaMetadataQuery, + MediaMetadataQueryResults, + MediaMetadataQueryResultsMap, + QueryResults, + QueryResultsType, + QueryReturnType, +} from '../types'; +import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine'; +import { + BrowseMediaStep, + BrowseMediaTarget, +} from '../../utils/ha/browse-media/browse-media-manager'; +import { allPromises, formatDate, isValidDate } from '../../utils/basic'; +import endOfDay from 'date-fns/endOfDay'; +import { + BROWSE_MEDIA_CACHE_SECONDS, + BrowseMedia, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_VIDEO, + RichBrowseMedia, +} from '../../utils/ha/browse-media/types'; +import parse from 'date-fns/parse'; +import { MotionEyeEventQueryResults } from './types'; +import orderBy from 'lodash-es/orderBy'; +import startOfDay from 'date-fns/startOfDay'; +import add from 'date-fns/add'; +import { + BrowseMediaCameraManagerEngine, + getViewMediaFromBrowseMediaArray, + isMediaWithinDates, +} from '../browse-media/engine-browse-media'; +import { BrowseMediaMetadata } from '../browse-media/types'; +import motioneyeLogo from './assets/motioneye-logo.svg'; + +class MotionEyeQueryResultsClassifier { + public static isMotionEyeEventQueryResults( + results: QueryResults, + ): results is MotionEyeEventQueryResults { + return ( + results.engine === Engine.MotionEye && results.type === QueryResultsType.Event + ); + } +} + +const MOTIONEYE_REPL_SUBSTITUTIONS: Record = { + '%Y': 'yyyy', + '%m': 'MM', + '%d': 'dd', + '%H': 'HH', + '%M': 'mm', + '%S': 'ss', +}; +const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g); + +export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine { + public getEngineType(): Engine { + return Engine.MotionEye; + } + + protected _convertMotionEyeTimeFormatToDateFNS(part: string): string { + return part.replace( + MOTIONEYE_REPL_REGEXP, + (_, key) => MOTIONEYE_REPL_SUBSTITUTIONS[key], + ); + } + + // Get metadata for a MotionEye media file. + protected _motionEyeMetadataGeneratorFile( + cameraID: string, + dateFormat: string | null, + media: BrowseMedia, + parent?: RichBrowseMedia, + ): BrowseMediaMetadata | null { + let startDate = parent?._metadata?.startDate ?? new Date(); + if (dateFormat) { + const extensionlessTitle = media.title.replace(/\.[^/.]+$/, ''); + startDate = parse(extensionlessTitle, dateFormat, startDate); + if (!isValidDate(startDate)) { + return null; + } + } + return { + cameraID: cameraID, + startDate: startDate, + // MotionEye only has start times, the event is effectively a 'point' + endDate: startDate, + }; + } + + // Get metadata for a MotionEye media directory. + protected _motionEyeMetadataGeneratorDirectory( + cameraID: string, + dateFormat: string | null, + media: BrowseMedia, + parent?: RichBrowseMedia, + ): BrowseMediaMetadata | null { + let startDate = parent?._metadata?.startDate ?? new Date(); + if (dateFormat) { + const parsedDate = parse(media.title, dateFormat, startDate); + if (!isValidDate(parsedDate)) { + return null; + } + startDate = startOfDay(parsedDate); + } + return { + cameraID: cameraID, + startDate: startDate, + endDate: parent?._metadata?.endDate ?? endOfDay(startDate), + }; + } + + // Get media directories that match a given criteria. + protected async _getMatchingDirectories( + hass: HomeAssistant, + cameras: CameraConfigs, + cameraID: string, + matchOptions?: { + start?: Date; + end?: Date; + hasClip?: boolean; + hasSnapshot?: boolean; + } | null, + engineOptions?: EngineOptions, + ): Promise[] | null> { + const cameraEntityID = cameras.get(cameraID)?.camera_entity; + const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null; + const configID = entity?.config_entry_id; + const deviceID = entity?.device_id; + const cameraConfig = cameras.get(cameraID); + + if (!configID || !deviceID || !cameraConfig) { + return null; + } + + const generateNextStep = ( + parts: string[], + media: BrowseMediaTarget[], + ): BrowseMediaStep[] => { + const next = parts.shift(); + if (!next) { + return []; + } + + const dateFormat = next.includes('%') + ? this._convertMotionEyeTimeFormatToDateFNS(next) + : null; + + return [ + { + targets: media, + metadataGenerator: ( + media: BrowseMedia, + parent?: RichBrowseMedia, + ) => + this._motionEyeMetadataGeneratorDirectory( + cameraID, + dateFormat, + media, + parent, + ), + matcher: (media: RichBrowseMedia) => + media.can_expand && + (!!dateFormat || media.title === next) && + isMediaWithinDates(media, matchOptions?.start, matchOptions?.end), + advance: (media) => generateNextStep(parts, media), + }, + ]; + }; + + // For motionEye snapshots and clips are mutually exclusive. + return await this._browseMediaManager.walkBrowseMedias( + hass, + [ + ...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot + ? generateNextStep( + cameraConfig.motioneye.movies.directory_pattern.split('/'), + [`media-source://motioneye/${configID}#${deviceID}#movies`], + ) + : []), + ...(matchOptions?.hasSnapshot !== false && !matchOptions?.hasClip + ? generateNextStep( + cameraConfig.motioneye.images.directory_pattern.split('/'), + [`media-source://motioneye/${configID}#${deviceID}#images`], + ) + : []), + ], + { + useCache: engineOptions?.useCache, + }, + ); + } + + public async getEvents( + hass: HomeAssistant, + cameras: CameraConfigs, + query: EventQuery, + engineOptions?: EngineOptions, + ): Promise { + // MotionEye does not support these query types and they will never match. + if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) { + return null; + } + + const output: EventQueryResultsMap = new Map(); + const getEventsForCamera = async (cameraID: string): Promise => { + const perCameraQuery = { ...query, cameraIDs: new Set([cameraID]) }; + const cachedResult = + engineOptions?.useCache ?? true ? this._requestCache.get(perCameraQuery) : null; + if (cachedResult) { + output.set(perCameraQuery, cachedResult as EventQueryResults); + return; + } + + const cameraConfig = cameras.get(cameraID); + if (!cameraConfig) { + return; + } + + const directories = await this._getMatchingDirectories( + hass, + cameras, + cameraID, + perCameraQuery, + engineOptions, + ); + if (!directories || !directories.length) { + return; + } + + const moviesDateFormat = this._convertMotionEyeTimeFormatToDateFNS( + cameraConfig.motioneye.movies.file_pattern, + ); + const imagesDateFormat = this._convertMotionEyeTimeFormatToDateFNS( + cameraConfig.motioneye.images.file_pattern, + ); + + const media = await this._browseMediaManager.walkBrowseMedias( + hass, + [ + { + targets: directories, + metadataGenerator: ( + media: BrowseMedia, + parent?: RichBrowseMedia, + ) => { + if ( + media.media_class === MEDIA_CLASS_IMAGE || + media.media_class === MEDIA_CLASS_VIDEO + ) { + return this._motionEyeMetadataGeneratorFile( + cameraID, + media.media_class === MEDIA_CLASS_IMAGE + ? imagesDateFormat + : moviesDateFormat, + media, + parent, + ); + } + return null; + }, + matcher: (media: RichBrowseMedia) => + !media.can_expand && + isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end), + }, + ], + { useCache: engineOptions?.useCache }, + ); + + // Sort by most recent then slice at the query limit. + const sortedMedia = orderBy( + media, + (media: RichBrowseMedia) => media._metadata?.startDate, + 'desc', + ).slice(0, perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT); + + const result: MotionEyeEventQueryResults = { + type: QueryResultsType.Event, + engine: Engine.MotionEye, + browseMedia: sortedMedia, + }; + + if (engineOptions?.useCache ?? true) { + this._requestCache.set( + perCameraQuery, + { ...result, cached: true }, + result.expiry, + ); + } + output.set(perCameraQuery, result); + }; + + await allPromises(query.cameraIDs, (cameraID) => getEventsForCamera(cameraID)); + return output.size ? output : null; + } + + public generateMediaFromEvents( + _hass: HomeAssistant, + _cameras: CameraConfigs, + _query: EventQuery, + results: QueryReturnType, + ): ViewMedia[] | null { + if (!MotionEyeQueryResultsClassifier.isMotionEyeEventQueryResults(results)) { + return null; + } + return getViewMediaFromBrowseMediaArray(results.browseMedia); + } + + public async getMediaMetadata( + hass: HomeAssistant, + cameras: CameraConfigs, + query: MediaMetadataQuery, + engineOptions?: EngineOptions, + ): Promise { + const output: MediaMetadataQueryResultsMap = new Map(); + if ((engineOptions?.useCache ?? true) && this._requestCache.has(query)) { + const cachedResult = ( + this._requestCache.get(query) + ); + if (cachedResult) { + output.set(query, cachedResult as MediaMetadataQueryResults); + return output; + } + } + + const days: Set = new Set(); + const getDaysForCamera = async (cameraID: string): Promise => { + const directories = await this._getMatchingDirectories( + hass, + cameras, + cameraID, + null, + engineOptions, + ); + for (const dayDirectory of directories ?? []) { + if (dayDirectory._metadata) { + days.add(formatDate(dayDirectory._metadata?.startDate)); + } + } + }; + + await allPromises(query.cameraIDs, (cameraID) => getDaysForCamera(cameraID)); + + const result: MediaMetadataQueryResults = { + type: QueryResultsType.MediaMetadata, + engine: Engine.MotionEye, + metadata: { + ...(days.size && { days: days }), + }, + expiry: add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }), + cached: false, + }; + + if (engineOptions?.useCache ?? true) { + this._requestCache.set(query, { ...result, cached: true }, result.expiry); + } + output.set(query, result); + return output; + } + + public getCameraMetadata( + hass: HomeAssistant, + cameraConfig: CameraConfig, + ): CameraManagerCameraMetadata { + const metadata = super.getCameraMetadata(hass, cameraConfig); + return { + ...metadata, + engineLogo: motioneyeLogo, + }; + } + + public getCameraEndpoints( + cameraConfig: CameraConfig, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _context?: CameraEndpointsContext, + ): CameraEndpoints | null { + const getUIEndpoint = (): CameraEndpoint | null => { + return cameraConfig.motioneye?.url + ? { + endpoint: cameraConfig.motioneye.url, + } + : null; + }; + + const ui = getUIEndpoint(); + return { + ...(ui && { ui: ui }), + }; + } +} diff --git a/src/camera-manager/motioneye/icon.ts b/src/camera-manager/motioneye/icon.ts new file mode 100644 index 00000000..f294c17e --- /dev/null +++ b/src/camera-manager/motioneye/icon.ts @@ -0,0 +1,51 @@ +// Converted from https://raw.githubusercontent.com/motioneye-project/motioneye/python2/motioneye/static/img/motioneye-icon.svg . +export const MOTIONEYE_ICON_SVG_VIEWBOX = '0 0 64 64'; +export const MOTIONEYE_ICON_SVG_PATH = + 'M 49.65,10.81 ' + + 'C 44.24,10.84 36.85,13.50 31.48,15.96 ' + + '25.84,13.92 20.04,10.69 13.50,10.84 ' + + '13.07,10.85 12.65,10.87 12.20,10.91 ' + + '12.20,10.91 7.08,11.33 7.08,11.33 ' + + '7.08,11.33 11.94,12.95 11.94,12.95 ' + + '18.62,15.13 24.49,16.51 29.66,25.48 ' + + '30.86,25.48 33.22,25.48 34.34,25.48 ' + + '39.49,16.57 45.66,15.08 52.02,12.95 ' + + '52.02,12.95 56.83,11.39 56.83,11.39 ' + + '56.83,11.39 51.83,10.91 51.83,10.91 ' + + '51.15,10.84 50.43,10.80 49.65,10.81 ' + + '49.65,10.81 49.65,10.81 49.65,10.81 Z ' + + 'M 32.00,5.00 ' + + 'C 26.53,5.00 21.45,6.75 17.20,9.54 ' + + '21.80,10.04 26.33,11.22 31.48,13.76 ' + + '36.69,11.11 42.02,10.00 46.83,9.45 ' + + '42.57,6.64 37.48,5.00 32.00,5.00 Z ' + + 'M 43.42,22.65 ' + + 'C 41.70,22.65 40.31,24.05 40.31,25.77 ' + + '40.31,27.49 41.70,28.88 43.42,28.88 ' + + '45.14,28.88 46.54,27.49 46.54,25.77 ' + + '46.54,24.05 45.14,22.65 43.42,22.65 Z ' + + 'M 20.58,22.65 ' + + 'C 18.86,22.65 17.46,24.05 17.46,25.77 ' + + '17.46,27.49 18.86,28.88 20.58,28.88 ' + + '22.30,28.88 23.69,27.49 23.69,25.77 ' + + '23.69,24.05 22.30,22.65 20.58,22.65 Z ' + + 'M 11.91,14.02 ' + + 'C 7.61,18.80 5.00,25.06 5.00,32.00 ' + + '5.00,46.91 17.09,59.00 32.00,59.00 ' + + '46.91,59.00 59.00,46.91 59.00,32.00 ' + + '59.00,25.09 56.40,18.80 52.12,14.02 ' + + '50.08,14.77 48.04,15.65 46.02,16.78 ' + + '49.92,17.91 52.77,21.53 52.77,25.77 ' + + '52.77,30.90 48.59,35.12 43.42,35.12 ' + + '39.04,35.12 35.36,32.09 34.34,28.04 ' + + '34.34,28.04 29.66,28.04 29.66,28.04 ' + + '28.65,32.09 24.96,35.12 20.58,35.12 ' + + '15.41,35.12 11.20,30.90 11.20,25.77 ' + + '11.20,21.48 14.16,17.83 18.14,16.75 ' + + '16.12,15.65 14.04,14.79 11.91,14.02 ' + + '11.91,14.02 11.91,14.02 11.91,14.02 Z ' + + 'M 32.00,30.96 ' + + 'C 32.64,33.35 33.33,35.72 36.15,37.19 ' + + '36.15,37.19 32.00,43.42 32.00,43.42 ' + + '32.00,43.42 27.85,37.19 27.85,37.19 ' + + '30.32,35.44 31.46,33.29 32.00,30.96 Z'; diff --git a/src/camera-manager/motioneye/types.ts b/src/camera-manager/motioneye/types.ts new file mode 100644 index 00000000..107854aa --- /dev/null +++ b/src/camera-manager/motioneye/types.ts @@ -0,0 +1,12 @@ +import { RichBrowseMedia } from '../../utils/ha/browse-media/types'; +import { BrowseMediaMetadata } from '../browse-media/types'; +import { Engine, EventQueryResults } from '../types'; + +// ================================ +// MotionEye concrete query results +// ================================ + +export interface MotionEyeEventQueryResults extends EventQueryResults { + engine: Engine.MotionEye; + browseMedia: RichBrowseMedia[]; +} diff --git a/src/camera-manager/range.ts b/src/camera-manager/range.ts new file mode 100644 index 00000000..c358395b --- /dev/null +++ b/src/camera-manager/range.ts @@ -0,0 +1,125 @@ +import orderBy from 'lodash-es/orderBy'; + +interface Range { + start: T; + end: T; +} + +export type DateRange = Range; + +interface MemoryRangeSetInterface { + hasCoverage(range: T): boolean; + add(range: T): void; + clear(): void; +} + +export class MemoryRangeSet implements MemoryRangeSetInterface { + protected _ranges: DateRange[]; + + constructor(ranges?: DateRange[]) { + this._ranges = ranges ?? []; + } + + public hasCoverage(range: DateRange): boolean { + return this._ranges.some((cachedRange) => + rangeIsEntirelyContained(cachedRange, range), + ); + } + + public add(range: DateRange): void { + this._ranges.push(range); + this._ranges = compressRanges(this._ranges); + } + + public clear(): void { + this._ranges = []; + } +} + +export interface ExpiringRange extends Range { + expires: Date; +} + +export class ExpiringMemoryRangeSet + implements MemoryRangeSetInterface> +{ + protected _ranges: ExpiringRange[]; + + constructor(ranges?: ExpiringRange[]) { + this._ranges = ranges ?? []; + } + + public hasCoverage(range: DateRange): boolean { + const now = new Date(); + return this._ranges.some( + (cachedRange) => + now < cachedRange.expires && rangeIsEntirelyContained(cachedRange, range), + ); + } + + public add(range: ExpiringRange): void { + this._expireOldRanges(); + this._ranges.push(range); + } + + protected _expireOldRanges(): void { + const now = new Date(); + this._ranges = this._ranges.filter((range) => now < range.expires); + } + + public clear(): void { + this._ranges = []; + } +} + +const rangeIsEntirelyContained = (bigger: DateRange, smaller: DateRange): boolean => { + return smaller.start >= bigger.start && smaller.end <= bigger.end; +}; + +export const rangesOverlap = (a: DateRange, b: DateRange): boolean => { + return ( + // a starts within the range of b. + (a.start >= b.start && a.start <= b.end) || + // a ends within the range of b. + (a.end >= b.start && a.end <= b.end) || + // a encompasses the entire range of b. + (a.start <= b.start && a.end >= b.end) + ); +}; + +export const compressRanges = ( + ranges: Range[], + toleranceSeconds = 0, +): Range[] => { + const compressedRanges: Range[] = []; + ranges = orderBy(ranges, (range) => range.start, 'asc'); + + let current: Range | null = null; + for (let i = 0; i < ranges.length; ++i) { + const item = ranges[i]; + const itemStartSeconds = + item.start instanceof Date ? item.start.getTime() : item.start; + + if (!current) { + current = { ...item }; + continue; + } + + const currentEndSeconds = + current.end instanceof Date ? current.end.getTime() : (current.end as number); + + if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) { + if (item.end > current.end) { + current.end = item.end; + } + } else { + compressedRanges.push(current); + current = { ...item }; + } + } + if (current) { + compressedRanges.push(current); + } + + return compressedRanges; +}; diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts new file mode 100644 index 00000000..7d929cb4 --- /dev/null +++ b/src/camera-manager/store.ts @@ -0,0 +1,121 @@ +import { CameraConfig } from '../types'; +import { ViewMedia } from '../view/media'; +import { CameraManagerEngine } from './engine'; +import { CameraConfigs, Engine } from './types'; + +type CameraManagerEngineCameraIDMap = Map>; + +export interface CameraManagerReadOnlyConfigStore { + getCameraConfig(cameraID: string): CameraConfig | null; + getCameraConfigForMedia(media: ViewMedia): CameraConfig | null; + + hasCameraID(cameraID: string): boolean; + hasVisibleCameraID(cameraID: string): boolean; + + getCameraCount(): number; + getVisibleCameraCount(): number; + + getCameras(): CameraConfigs; + getVisibleCameras(): CameraConfigs; + + getCameraIDs(): Set; + getVisibleCameraIDs(): Set; +} + +export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { + protected _allConfigs: Map = new Map(); + protected _visibleConfigs: Map = new Map(); + protected _enginesByCamera: Map = new Map(); + protected _enginesByType: Map = new Map(); + + public addCamera( + cameraID: string, + cameraConfig: CameraConfig, + engine: CameraManagerEngine, + ): void { + if (!cameraConfig.hide) { + this._visibleConfigs.set(cameraID, cameraConfig); + } + this._allConfigs.set(cameraID, cameraConfig); + this._enginesByCamera.set(cameraID, engine); + this._enginesByType.set(engine.getEngineType(), engine); + } + + public getCameraConfig(cameraID: string): CameraConfig | null { + return this._allConfigs.get(cameraID) ?? null; + } + + public hasCameraID(cameraID: string): boolean { + return this._allConfigs.has(cameraID); + } + public hasVisibleCameraID(cameraID: string): boolean { + return this._visibleConfigs.has(cameraID); + } + + public getCameraCount(): number { + return this._allConfigs.size; + } + public getVisibleCameraCount(): number { + return this._visibleConfigs.size; + } + + public getCameras(): CameraConfigs { + return this._allConfigs; + } + public getVisibleCameras(): CameraConfigs { + return this._visibleConfigs; + } + + public getCameraIDs(): Set { + return new Set(this._allConfigs.keys()); + } + public getVisibleCameraIDs(): Set { + return new Set(this._visibleConfigs.keys()); + } + + public getCameraConfigForMedia(media: ViewMedia): CameraConfig | null { + const cameraID = media.getCameraID(); + if (!cameraID) { + return null; + } + return this.getCameraConfig(cameraID); + } + + public getEngineOfType(engine: Engine): CameraManagerEngine | null { + return this._enginesByType.get(engine) ?? null; + } + + public getEngineForCameraID(cameraID: string): CameraManagerEngine | null { + return this._enginesByCamera.get(cameraID) ?? null; + } + + public getEnginesForCameraIDs( + cameraIDs: Set, + ): CameraManagerEngineCameraIDMap | null { + const output: CameraManagerEngineCameraIDMap = new Map(); + + for (const cameraID of cameraIDs) { + const engine = this.getEngineForCameraID(cameraID); + if (!engine) { + continue; + } + if (!output.has(engine)) { + output.set(engine, new Set()); + } + output.get(engine)?.add(cameraID); + } + return output.size ? output : null; + } + + public getEngineForMedia(media: ViewMedia): CameraManagerEngine | null { + const cameraID = media.getCameraID(); + if (!cameraID) { + return null; + } + return this.getEngineForCameraID(cameraID); + } + + public getAllEngines(): CameraManagerEngine[] { + return [...this._enginesByType.values()]; + } +} diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts new file mode 100644 index 00000000..19b508cc --- /dev/null +++ b/src/camera-manager/types.ts @@ -0,0 +1,207 @@ +import { CameraConfig, FrigateCardView } from '../types'; +import { ViewMedia } from '../view/media'; + +// ==== +// Base +// ==== + +export enum QueryType { + Event = 'event-query', + Recording = 'recording-query', + RecordingSegments = 'recording-segments-query', + MediaMetadata = 'media-metadata', +} + +export enum QueryResultsType { + Event = 'event-results', + Recording = 'recording-results', + RecordingSegments = 'recording-segments-results', + MediaMetadata = 'media-metadata-results', +} + +export enum Engine { + Frigate = 'frigate', + Generic = 'generic', + MotionEye = 'motioneye', +} + +export interface DataQuery { + type: QueryType; + cameraIDs: Set; +} +export type PartialDataQuery = Partial; + +interface TimeBasedDataQuery { + start: Date; + end: Date; +} + +interface LimitedDataQuery { + limit: number; +} + +export interface MediaQuery + extends DataQuery, + Partial, + Partial { + favorite?: boolean; +} + +export interface QueryResults { + type: QueryResultsType; + engine: Engine; + expiry?: Date; + cached?: boolean; +} + +// Generic recording segment type (inspired by Frigate recording segments). +export interface RecordingSegment { + start_time: number; + end_time: number; + id: string; +} + +export type QueryReturnType = QT extends EventQuery + ? EventQueryResults + : QT extends RecordingQuery + ? RecordingQueryResults + : QT extends RecordingSegmentsQuery + ? RecordingSegmentsQueryResults + : QT extends MediaMetadataQuery + ? MediaMetadataQueryResults + : never; +export type PartialQueryConcreteType = PQT extends PartialEventQuery + ? EventQuery + : PQT extends PartialRecordingQuery + ? RecordingQuery + : PQT extends PartialRecordingSegmentsQuery + ? RecordingSegmentsQuery + : never; + +export type ResultsMap = Map>; +export type EventQueryResultsMap = ResultsMap; +export type RecordingQueryResultsMap = ResultsMap; +export type RecordingSegmentsQueryResultsMap = ResultsMap; +export type MediaMetadataQueryResultsMap = ResultsMap; + +export interface MediaMetadata { + days?: Set; + tags?: Set; + where?: Set; + what?: Set; +} + +interface BaseCapabilities { + canFavoriteEvents: boolean; + canFavoriteRecordings: boolean; + canSeek: boolean; + + supportsClips: boolean; + supportsRecordings: boolean; + supportsSnapshots: boolean; + supportsTimeline: boolean; +} + +export type CameraManagerCapabilities = BaseCapabilities; +export type CameraManagerCameraCapabilities = BaseCapabilities; +export interface CameraManagerMediaCapabilities { + canFavorite: boolean; + canDownload: boolean; +} + +export interface CameraManagerCameraMetadata { + title: string; + icon: string; + engineLogo?: string; +} + +export interface CameraEndpointsContext { + media?: ViewMedia; + view?: FrigateCardView; +} + +export interface CameraEndpoint { + endpoint: string; + sign?: boolean; +} + +export interface CameraEndpoints { + ui?: CameraEndpoint; + go2rtc?: CameraEndpoint; + jsmpeg?: CameraEndpoint; + webrtcCard?: CameraEndpoint; +} + +export type CameraConfigs = Map; + +export interface EngineOptions { + useCache?: boolean; +} + +// =========== +// Event Query +// =========== + +export interface EventQuery extends MediaQuery { + type: QueryType.Event; + + // Frigate equivalent: has_snapshot + hasSnapshot?: boolean; + + // Frigate equivalent: has_clip + hasClip?: boolean; + + // Frigate equivalent: label + what?: Set; + + // Frigate equivalent: sub_label + tags?: Set; + + // Frigate equivalent: zone + where?: Set; +} +export type PartialEventQuery = Partial; + +export interface EventQueryResults extends QueryResults { + type: QueryResultsType.Event; +} + +// =============== +// Recording Query +// =============== + +export interface RecordingQuery extends MediaQuery { + type: QueryType.Recording; +} +export type PartialRecordingQuery = Partial; + +export interface RecordingQueryResults extends QueryResults { + type: QueryResultsType.Recording; +} + +// ======================== +// Recording Segments Query +// ======================== + +export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery { + type: QueryType.RecordingSegments; +} +export type PartialRecordingSegmentsQuery = Partial; + +export interface RecordingSegmentsQueryResults extends QueryResults { + type: QueryResultsType.RecordingSegments; + segments: RecordingSegment[]; +} + +// ==================== +// Media metadata Query +// ==================== + +export interface MediaMetadataQuery extends DataQuery { + type: QueryType.MediaMetadata; +} + +export interface MediaMetadataQueryResults extends QueryResults { + type: QueryResultsType.MediaMetadata; + metadata: MediaMetadata; +} diff --git a/src/camera-manager/util.ts b/src/camera-manager/util.ts new file mode 100644 index 00000000..e5e56812 --- /dev/null +++ b/src/camera-manager/util.ts @@ -0,0 +1,55 @@ +import startOfHour from 'date-fns/startOfHour'; +import endOfHour from 'date-fns/endOfHour'; +import startOfDay from 'date-fns/startOfDay'; +import endOfDay from 'date-fns/endOfDay'; +import endOfMinute from 'date-fns/endOfMinute'; +import { DateRange } from './range'; +import orderBy from 'lodash-es/orderBy'; +import uniqBy from 'lodash-es/uniqBy'; +import { ViewMedia } from '../view/media'; + +export const convertRangeToCacheFriendlyTimes = ( + range: DateRange, + options?: { + endCap?: boolean; + }, +): DateRange => { + const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000; + let cacheableStart: Date; + let cacheableEnd: Date; + + if (widthSeconds <= 60 * 60) { + cacheableStart = startOfHour(range.start); + cacheableEnd = endOfHour(range.end); + } else { + cacheableStart = startOfDay(range.start); + cacheableEnd = endOfDay(range.end); + } + + if (options?.endCap) { + cacheableEnd = endOfMinute(capEndDate(cacheableEnd)); + } + + return { + start: cacheableStart, + end: cacheableEnd, + }; +}; + +export const capEndDate = (end: Date): Date => { + const now = new Date(); + return end > now ? now : end; +}; + +export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => { + return orderBy( + // Ensure uniqueness by the ID (if specified), otherwise all elements + // are assumed to be unique. + uniqBy(mediaArray, (media) => media.getID() ?? media), + + // Sort all items leading oldest -> youngest (so media is loaded in this + // order in the viewer which matches the left-to-right timeline order). + (media) => media.getStartTime(), + 'asc', + ); +}; diff --git a/src/card-condition.ts b/src/card-condition.ts index 26f3a941..59dbf0d0 100644 --- a/src/card-condition.ts +++ b/src/card-condition.ts @@ -1,24 +1,28 @@ -import type { +import { FrigateCardCondition, + FrigateCardConfig, + frigateConditionalSchema, OverrideConfigurationKey, RawFrigateCardConfig, } from './types'; import { HassEntities } from 'home-assistant-js-websocket'; -import { cloneDeep, merge } from 'lodash-es'; +import merge from 'lodash-es/merge'; +import { copyConfig } from './config-mgmt'; export interface ConditionState { view?: string; fullscreen?: boolean; + expand?: boolean; camera?: string; state?: HassEntities; - mediaLoaded?: boolean; + media_loaded?: boolean; } class ConditionStateRequestEvent extends Event { public conditionState?: ConditionState; } -export function evaluateCondition( +function evaluateCondition( condition?: Readonly, state?: Readonly, ): boolean { @@ -34,6 +38,10 @@ export function evaluateCondition( result &&= state.fullscreen !== undefined && condition.fullscreen == state.fullscreen; } + if (condition?.expand !== undefined) { + result &&= + state.expand !== undefined && condition.expand == state.expand; + } if (condition?.camera?.length) { result &&= !!state.camera && condition.camera.includes(state.camera); } @@ -49,9 +57,12 @@ export function evaluateCondition( state.state[stateTest.entity].state !== stateTest.state_not))); } } - if (condition?.mediaLoaded !== undefined) { + if (condition?.media_loaded !== undefined) { result &&= - state.mediaLoaded !== undefined && condition.mediaLoaded == state.mediaLoaded; + state.media_loaded !== undefined && condition.media_loaded == state.media_loaded; + } + if (condition?.media_query) { + result &&= window.matchMedia(condition.media_query).matches; } return result; } @@ -109,7 +120,7 @@ export function getOverriddenConfig( overrides: Readonly | undefined, conditionState?: Readonly, ): RawFrigateCardConfig { - const output = cloneDeep(config); + const output = copyConfig(config); let overridden = false; if (overrides) { for (const override of overrides) { @@ -137,3 +148,83 @@ export function getOverridesByKey( })) ?? [] ); } + +export class CardConditionManager { + // Whether or not to include HA state in ConditionState. Doing so increases + // CPU usage as HA state is pumped out very fast, so this is only enabled if + // the configuration needs to consume it. + protected _hasHAStateConditions = false; + + protected _callback: () => void; + protected _mediaQueries: MediaQueryList[] = []; + protected _boundTriggerChange = this._triggerChange.bind(this); + + constructor(config: FrigateCardConfig, callback: () => void) { + this._initConditions(config); + this._callback = callback; + } + + /** + * Destroy the object. + */ + public destroy(): void { + this._mediaQueries.forEach((mql) => + mql.removeEventListener('change', this._boundTriggerChange), + ); + this._mediaQueries = []; + } + + /** + * Determine if the conditions have state conditions. + */ + get hasHAStateConditions(): boolean { + return this._hasHAStateConditions; + } + + /** + * Trigger the callback. + * @param _ Ignored parameter. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _triggerChange(_: MediaQueryListEvent): void { + this._callback(); + } + + /** + * Init the conditions. + * @param config The card configuration. + */ + protected _initConditions(config: FrigateCardConfig): void { + const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => { + const conditions: FrigateCardCondition[] = []; + config.overrides?.forEach((override) => conditions.push(override.conditions)); + + // Element conditions can be arbitrarily nested underneath conditionals and + // custom elements that this card may not known. Here we recursively parse + // down the elements tree, parsing as we go to find valid conditions. + const getElementsConditions = (data: unknown): void => { + const parseResult = frigateConditionalSchema.safeParse(data); + if (parseResult.success) { + conditions.push(parseResult.data.conditions); + parseResult.data.elements?.forEach(getElementsConditions); + } else if (data && typeof data === 'object') { + Object.keys(data).forEach((key) => getElementsConditions(data[key])); + } + }; + config.elements?.forEach(getElementsConditions); + return conditions; + }; + + const conditions = getAllConditions(config); + this._hasHAStateConditions = conditions.some( + (condition) => !!condition.state?.length, + ); + conditions.forEach((condition) => { + if (condition.media_query) { + const mql = window.matchMedia(condition.media_query); + mql.addEventListener('change', this._boundTriggerChange); + this._mediaQueries.push(mql); + } + }); + } +} diff --git a/src/card.ts b/src/card.ts index 7aa66404..1b7ace09 100644 --- a/src/card.ts +++ b/src/card.ts @@ -7,65 +7,59 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; +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 { StyleInfo, styleMap } from 'lit/directives/style-map.js'; -import { until } from 'lit/directives/until.js'; -import { throttle } from 'lodash-es'; +import cloneDeep from 'lodash-es/cloneDeep'; +import isEqual from 'lodash-es/isEqual'; +import merge from 'lodash-es/merge'; +import throttle from 'lodash-es/throttle'; import screenfull from 'screenfull'; +import { ViewContext } from 'view'; +import 'web-dialog'; import { z } from 'zod'; +import pkg from '../package.json'; import { actionHandler } from './action-handler-directive.js'; +import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js'; +import { CameraManager } from './camera-manager/manager.js'; import { + CardConditionManager, ConditionState, conditionStateRequestHandler, 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 './components/views.js'; +import { FrigateCardViews } from './components/views.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 { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA, REPO_URL } from './const.js'; +import { getLanguage, loadLanguages, localize } from './localize/localize.js'; +import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js'; import cardStyle from './scss/card.scss'; import { Actions, ActionType, CameraConfig, - EntityList, - ExtendedEntity, + CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig, frigateCardConfigSchema, FrigateCardCustomAction, + FrigateCardError, FrigateCardView, FRIGATE_CARD_VIEWS_USER_SPECIFIED, + FRIGATE_CARD_VIEW_DEFAULT, MediaLoadedInfo, - MEDIA_TYPE_IMAGE, - MEDIA_TYPE_VIDEO, - MESSAGE_TYPE_PRIORITIES, MenuButton, Message, + MESSAGE_TYPE_PRIORITIES, RawFrigateCardConfig, } from './types.js'; import { @@ -75,31 +69,29 @@ import { frigateCardHasAction, getActionConfigGivenAction, } from './utils/action.js'; -import { contentsChanged, errorToConsole } from './utils/basic.js'; -import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js'; +import { errorToConsole } from './utils/basic.js'; +import { getAllDependentCameras } from './utils/camera.js'; +import { log } from './utils/debug.js'; +import { downloadMedia } from './utils/download.js'; import { getEntityIcon, getEntityTitle, getHassDifferences, - homeAssistantSignPath, + isCardInPanel, 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 { EntityCache } from './utils/ha/entity-registry/cache.js'; +import { EntityRegistryManager } from './utils/ha/entity-registry/index.js'; +import { Entity } from './utils/ha/entity-registry/types.js'; import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { supportsFeature } from './utils/ha/update.js'; +import { FrigateCardInitializer } from './utils/initializer.js'; import { isValidMediaLoadedInfo } from './utils/media-info.js'; -import { View } from './view.js'; -import pkg from '../package.json'; -import { ViewContext } from 'view'; +import { getActionsFromQueryString } from './utils/querystring.js'; +import { View } from './view/view.js'; /** A note on media callbacks: * @@ -148,11 +140,18 @@ console.info( documentationURL: REPO_URL, }); +enum InitializationAspect { + LANGUAGES = 'languages', + SIDE_LOAD_ELEMENTS = 'side-load-elements', + MEDIA_PLAYERS = 'media-players', + CAMERAS = 'cameras', +} + /** * Main FrigateCard class. */ @customElement('frigate-card') -export class FrigateCard extends LitElement { +class FrigateCard extends LitElement { @state() protected _hass?: ExtendedHomeAssistant; @@ -164,19 +163,28 @@ export class FrigateCard extends LitElement { protected _config!: FrigateCardConfig; protected _rawConfig?: RawFrigateCardConfig; + @state() + protected _cardWideConfig?: CardWideConfig; + @state() protected _overriddenConfig?: FrigateCardConfig; @state() protected _view?: View; + // Whether or not the card is in panel mode on the dashboard. + @property({ attribute: 'panel', type: Boolean, reflect: true }) + protected _panel = false; + + @state() + protected _expand?: boolean = false; + protected _conditionState?: ConditionState; protected _refMenu: Ref = createRef(); protected _refMain: Ref = createRef(); protected _refElements: Ref = createRef(); - protected _refImage: Ref = createRef(); - protected _refLive: Ref = createRef(); + protected _refViews: Ref = createRef(); // user interaction timer ("screensaver" functionality, return to default // view after user interaction). @@ -192,25 +200,34 @@ export class FrigateCard extends LitElement { // Array of dynamic menu buttons to be added to menu. protected _dynamicMenuButtons: MenuButton[] = []; - @state() - protected _cameras?: Map; - // Error/info message to render. protected _message: Message | null = null; // A cache of resolved media URLs/mimetypes for use in the whole card. protected _resolvedMediaCache = new ResolvedMediaCache(); + protected _cameraManager?: CameraManager; + + protected _entityRegistryManager: EntityRegistryManager; + // 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; + protected _conditionManager: CardConditionManager | null = null; + + protected _mediaPlayers?: string[]; + + protected _initializer = new FrigateCardInitializer(); + + constructor() { + super(); + this._entityRegistryManager = new EntityRegistryManager(new EntityCache()); + } + /** * Set the Home Assistant object. */ @@ -227,13 +244,15 @@ export class FrigateCard extends LitElement { if (this._refElements.value) { this._refElements.value.hass = this._hass; } - if (this._refImage.value) { - this._refImage.value.hass = this._hass; + if (this._refViews.value) { + this._refViews.value.hass = this._hass; } } - // HA entity state is part of the condition state. - this._generateConditionState(); + if (this._conditionManager?.hasHAStateConditions) { + // HA entity state is part of the condition state. + this._generateConditionState(); + } // Dark mode may depend on HASS. this._setLightOrDarkMode(); @@ -244,6 +263,7 @@ export class FrigateCard extends LitElement { * @returns A LovelaceCardEditor element. */ public static async getConfigElement(): Promise { + await import('./editor.js'); return document.createElement('frigate-card-editor'); } @@ -277,17 +297,20 @@ export class FrigateCard extends LitElement { this._conditionState = { view: this._view?.view, fullscreen: screenfull.isEnabled && screenfull.isFullscreen, + expand: this._expand, camera: this._view?.camera, - state: this._hass?.states, - mediaLoaded: !!this._currentMediaLoadedInfo, + media_loaded: !!this._currentMediaLoadedInfo, + ...(this._conditionManager?.hasHAStateConditions && { + state: this._hass?.states, + }), }; // 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._refViews.value) { + this._refViews.value.conditionState = this._conditionState; } if (this._refElements.value) { this._refElements.value.conditionState = this._conditionState; @@ -301,7 +324,15 @@ export class FrigateCard extends LitElement { // Save on Lit re-rendering costs by only updating the configuration if it // actually changes. - if (contentsChanged(overriddenConfig, this._overriddenConfig)) { + if (!isEqual(overriddenConfig, this._overriddenConfig)) { + if ( + !isEqual(overriddenConfig.cameras, this._overriddenConfig?.cameras) || + !isEqual(overriddenConfig.cameras_global, this._overriddenConfig?.cameras_global) + ) { + // Uninitialize the cameras (they will be re-initialized on the render + // cycle triggered by updating the overridden config) below. + this._initializer.uninitialize(InitializationAspect.CAMERAS); + } this._overriddenConfig = overriddenConfig; } } @@ -370,6 +401,22 @@ export class FrigateCard extends LitElement { protected _getMenuButtons(): MenuButton[] { const buttons: MenuButton[] = []; + const visibleCameras = this._cameraManager?.getStore().getVisibleCameras(); + const selectedCameraID = this._view?.camera; + const selectedCameraConfig = this._getSelectedCameraConfig(); + const allSelectedCameraIDs = getAllDependentCameras( + this._cameraManager, + selectedCameraID, + ); + const selectedMedia = this._view?.queryResults?.getSelectedResult(); + + const cameraCapabilities = allSelectedCameraIDs + ? this._cameraManager?.getAggregateCameraCapabilities(allSelectedCameraIDs) + : null; + const mediaCapabilities = selectedMedia + ? this._cameraManager?.getMediaCapabilities(selectedMedia) + : null; + buttons.push({ // Use a magic icon value that the menu will use to render the custom // Frigate icon. @@ -385,18 +432,22 @@ export class FrigateCard extends LitElement { ) as FrigateCardCustomAction, }); - if (this._cameras && this._cameras.size > 1) { - const menuItems = Array.from(this._cameras, ([camera, config]) => { + if (visibleCameras) { + const menuItems = Array.from(visibleCameras, ([cameraID, config]) => { const action = createFrigateCardCustomAction('camera_select', { - camera: camera, + camera: cameraID, }); + const metadata = this._hass + ? this._cameraManager?.getCameraMetadata(this._hass, cameraID) ?? undefined + : undefined; + return { enabled: true, - icon: getCameraIcon(this._hass, config), + icon: metadata?.icon, entity: config.camera_entity, state_color: true, - title: getCameraTitle(this._hass, config), - selected: this._view?.camera === camera, + title: metadata?.title, + selected: this._view?.camera === cameraID, ...(action && { tap_action: action }), }; }); @@ -410,6 +461,61 @@ export class FrigateCard extends LitElement { }); } + if (selectedCameraID && allSelectedCameraIDs && this._view?.is('live')) { + const dependencies = [...allSelectedCameraIDs]; + const override = this._view?.context?.live?.overrides?.get(selectedCameraID); + + if (dependencies.length === 2) { + // If there are only two dependencies (the main camera, and 1 other) + // then use a button not a menu to toggle. + buttons.push({ + icon: 'mdi:video-input-component', + style: + override && override !== selectedCameraID ? this._getEmphasizedStyle() : {}, + title: localize('config.menu.buttons.substreams'), + ...this._getConfig().menu.buttons.substreams, + type: 'custom:frigate-card-menu-icon', + tap_action: createFrigateCardCustomAction('live_substream_select', { + camera: + override === undefined || override === dependencies[0] + ? dependencies[1] + : dependencies[0], + }) as FrigateCardCustomAction, + }); + } else if (dependencies.length > 2) { + const menuItems = Array.from(dependencies, (cameraID) => { + const action = createFrigateCardCustomAction('live_substream_select', { + camera: cameraID, + }); + const metadata = this._hass + ? this._cameraManager?.getCameraMetadata(this._hass, cameraID) ?? undefined + : undefined; + const cameraConfig = this._cameraManager?.getStore().getCameraConfig(cameraID); + return { + enabled: true, + icon: metadata?.icon, + entity: cameraConfig?.camera_entity, + state_color: true, + title: metadata?.title, + selected: + (this._view?.context?.live?.overrides?.get(selectedCameraID) ?? + selectedCameraID) === cameraID, + ...(action && { tap_action: action }), + }; + }); + + buttons.push({ + icon: 'mdi:video-input-component', + title: localize('config.menu.buttons.substreams'), + style: + override && override !== selectedCameraID ? this._getEmphasizedStyle() : {}, + ...this._getConfig().menu.buttons.substreams, + type: 'custom:frigate-card-menu-submenu', + items: menuItems, + }); + } + } + buttons.push({ icon: 'mdi:cctv', ...this._getConfig().menu.buttons.live, @@ -419,16 +525,7 @@ export class FrigateCard extends LitElement { 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 (unless there are dependent cameras). - if ( - cameraConfig?.frigate.camera_name && - (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE || - cameraConfig.dependencies.cameras.length || - cameraConfig.dependencies.all_cameras) - ) { + if (cameraCapabilities?.supportsClips) { buttons.push({ icon: 'mdi:filmstrip', ...this._getConfig().menu.buttons.clips, @@ -440,14 +537,7 @@ export class FrigateCard extends LitElement { }); } - // Don't show `snapshots` button if there's no `camera_name` (e.g. non-Frigate - // cameras), or is birdseye (unless there are dependent cameras). - if ( - cameraConfig?.frigate.camera_name && - (cameraConfig?.frigate.camera_name !== CAMERA_BIRDSEYE || - cameraConfig?.dependencies.cameras.length || - cameraConfig?.dependencies.all_cameras) - ) { + if (cameraCapabilities?.supportsSnapshots) { buttons.push({ icon: 'mdi:camera', ...this._getConfig().menu.buttons.snapshots, @@ -463,6 +553,22 @@ export class FrigateCard extends LitElement { }); } + if (cameraCapabilities?.supportsRecordings) { + buttons.push({ + icon: 'mdi:album', + ...this._getConfig().menu.buttons.recordings, + type: 'custom:frigate-card-menu-icon', + title: localize('config.view.views.recordings'), + style: this._view?.is('recordings') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction( + 'recordings', + ) as FrigateCardCustomAction, + hold_action: createFrigateCardCustomAction( + 'recording', + ) as FrigateCardCustomAction, + }); + } + buttons.push({ icon: 'mdi:image', ...this._getConfig().menu.buttons.image, @@ -474,13 +580,7 @@ export class FrigateCard extends LitElement { // 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, - ) - ) { + if (cameraCapabilities?.supportsTimeline) { buttons.push({ icon: 'mdi:chart-gantt', ...this._getConfig().menu.buttons.timeline, @@ -491,10 +591,7 @@ export class FrigateCard extends LitElement { }); } - if ( - !this._isBeingCasted() && - (this._view?.isViewerView() || (this._view?.is('timeline') && !!this._view?.media)) - ) { + if (mediaCapabilities?.canDownload && !this._isBeingCasted()) { buttons.push({ icon: 'mdi:download', ...this._getConfig().menu.buttons.download, @@ -504,14 +601,14 @@ export class FrigateCard extends LitElement { }); } - if (cameraConfig?.frigate.url) { + if (this._getCameraURLFromContext()) { buttons.push({ icon: 'mdi:web', - ...this._getConfig().menu.buttons.frigate_ui, + ...this._getConfig().menu.buttons.camera_ui, type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.frigate_ui'), + title: localize('config.menu.buttons.camera_ui'), tap_action: createFrigateCardCustomAction( - 'frigate_ui', + 'camera_ui', ) as FrigateCardCustomAction, }); } @@ -529,29 +626,21 @@ export class FrigateCard extends LitElement { }); } - 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; - }; + buttons.push({ + icon: this._expand ? 'mdi:arrow-collapse-all' : 'mdi:arrow-expand-all', + ...this._getConfig().menu.buttons.expand, + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.expand'), + tap_action: createFrigateCardCustomAction('expand') as FrigateCardCustomAction, + style: this._expand ? this._getEmphasizedStyle() : {}, + }); - const mediaPlayers = Object.keys(this._hass?.states || {}).filter( - isValidMediaPlayer, - ); if ( - mediaPlayers.length && + this._mediaPlayers?.length && (this._view?.isViewerView() || - (this._view?.is('live') && cameraConfig?.camera_entity)) + (this._view?.is('live') && selectedCameraConfig?.camera_entity)) ) { - const mediaPlayerItems = mediaPlayers.map((playerEntityID) => { + const mediaPlayerItems = this._mediaPlayers.map((playerEntityID) => { const title = getEntityTitle(this._hass, playerEntityID) || playerEntityID; const state = this._hass?.states[playerEntityID]; const playAction = createFrigateCardCustomAction('media_player', { @@ -619,204 +708,15 @@ export class FrigateCard extends LitElement { } } - /** - * 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 (!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. - } - } - - 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'), - type: 'error', - context: config, - }); - errorFree = false; - } else if (cameras.has(id)) { - this._setMessageAndUpdate({ - message: localize('error.duplicate_camera_id'), - type: 'error', - context: config, - }); - errorFree = false; - } else { - cameras.set(id, config); - } - }; - - if (this._getConfig().cameras && Array.isArray(this._getConfig().cameras)) { - // Cameras are loaded sequentially rather than in parallel to preserve the - // order of the input camera array. - for (const camera of this._getConfig().cameras) { - await addCameraConfig(camera); - } - } - - if (!cameras.size) { - return this._setMessageAndUpdate({ - message: localize('error.no_cameras'), - type: 'error', - }); - errorFree = false; - } - - if (errorFree) { - this._cameras = cameras; - } - } - /** * Get the camera configuration for the selected camera. * @returns The CameraConfig object or null if not found. */ protected _getSelectedCameraConfig(): CameraConfig | null { - if (!this._cameras || !this._cameras.size || !this._view?.camera) { + if (!this._view || !this._cameraManager) { return null; } - return this._cameras.get(this._view.camera) || null; - } - - /** - * Get the Frigate camera name from an entity. - * @returns The Frigate camera name or null if unavailable. - */ - 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']; - } - } - return null; + return this._cameraManager.getStore().getCameraConfig(this._view.camera); } /** @@ -890,9 +790,9 @@ export class FrigateCard extends LitElement { throw new Error(localize('error.invalid_configuration')); } - const configUpgradeable = isConfigUpgradeable(inputConfig); const parseResult = frigateCardConfigSchema.safeParse(inputConfig); if (!parseResult.success) { + const configUpgradeable = isConfigUpgradeable(inputConfig); const hint = this._getParseErrorPaths(parseResult.error); let upgradeMessage = ''; if (configUpgradeable && getLovelace().mode !== 'yaml') { @@ -906,7 +806,10 @@ export class FrigateCard extends LitElement { : localize('error.invalid_configuration_no_hint')), ); } - const config = parseResult.data; + const config = + parseResult.data.performance.profile !== 'low' + ? parseResult.data + : setLowPerformanceProfile(inputConfig, parseResult.data); if (config.test_gui) { getLovelace().setEditMode(true); @@ -914,12 +817,25 @@ export class FrigateCard extends LitElement { this._rawConfig = inputConfig; this._config = config; + this._cardWideConfig = { + performance: config.performance, + debug: config.debug, + }; + this._overriddenConfig = undefined; - this._cameras = undefined; + this._cameraManager = undefined; this._view = undefined; this._message = null; + + this._conditionManager?.destroy(); + this._conditionManager = new CardConditionManager( + config, + this._generateConditionState.bind(this), + ); + this._generateConditionState(); this._setLightOrDarkMode(); + this._setPropertiesForMinMaxHeight(); this._untrigger(); } @@ -932,13 +848,19 @@ export class FrigateCard extends LitElement { } protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { + log(this._cardWideConfig, `Frigate Card view change: `, args?.view ?? '[default]'); const changeView = (view: View): void => { - if (View.isMediaChange(this._view, view)) { + if (View.isMajorMediaChange(this._view, view)) { this._currentMediaLoadedInfo = null; } + if (this._view?.view !== view.view) { + this._resetMainScroll(); + } + + View.adoptFromViewIfAppropriate(view, this._view); + this._view = view; this._generateConditionState(); - this._resetMainScroll(); }; if (args?.resetMessage ?? true) { @@ -947,24 +869,27 @@ export class FrigateCard extends LitElement { if (!args?.view) { // Load the default view. - let camera; - if (this._cameras?.size) { - 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; - camera = keys[targetIndex]; - } else { - // Reset to the default camera. - camera = this._cameras.keys().next().value; + let cameraID: string | null = null; + if (this._cameraManager) { + const cameras = this._cameraManager.getStore().getVisibleCameras(); + if (cameras) { + if (this._view?.camera && this._getConfig().view.update_cycle_camera) { + const keys = Array.from(cameras.keys()); + const currentIndex = keys.indexOf(this._view.camera); + const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; + cameraID = keys[targetIndex]; + } else { + // Reset to the default camera. + cameraID = cameras.keys().next().value; + } } } - if (camera) { + if (cameraID) { changeView( new View({ view: this._getConfig().view.default, - camera: camera, + camera: cameraID, }), ); @@ -1014,15 +939,28 @@ export class FrigateCard extends LitElement { /** * 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; - } - }); + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('_cardWideConfig')) { + setPerformanceCSSStyles(this, this._cardWideConfig?.performance); } + + this._initializeBackground(); + + if (changedProps.has('_view')) { + this._setPropertiesForExpandedMode(); + } + } + + protected _setPropertiesForMinMaxHeight(): void { + this.style.setProperty( + '--frigate-card-max-height', + this._getConfig().dimensions.max_height, + ); + + this.style.setProperty( + '--frigate-card-min-height', + this._getConfig().dimensions.min_height, + ); } /** @@ -1049,7 +987,8 @@ export class FrigateCard extends LitElement { let changedCamera = false; let triggerChanges = false; - for (const [camera, config] of this._cameras?.entries() ?? []) { + const cameras = this._cameraManager?.getStore().getVisibleCameras(); + for (const [cameraID, config] of cameras?.entries() ?? []) { const triggerEntities = config.triggers.entities ?? []; const diffs = getHassDifferences(this._hass, oldHass, triggerEntities, { stateOnly: true, @@ -1059,10 +998,10 @@ export class FrigateCard extends LitElement { (entity) => !isTriggeredState(this._hass?.states[entity]), ); if (shouldTrigger) { - this._triggers.set(camera, now); + this._triggers.set(cameraID, now); triggerChanges = true; - } else if (shouldUntrigger && this._triggers.has(camera)) { - this._triggers.delete(camera); + } else if (shouldUntrigger && this._triggers.has(cameraID)) { + this._triggers.delete(cameraID); triggerChanges = true; } } @@ -1132,12 +1071,185 @@ export class FrigateCard extends LitElement { } } + protected _handleThrownError(error: unknown) { + if (error instanceof Error) { + errorToConsole(error); + } + if (error instanceof FrigateCardError) { + this._setMessageAndUpdate({ + message: error.message, + type: 'error', + context: error.context, + }); + } + } + + protected async _initializeCameras( + hass: HomeAssistant, + config: FrigateCardConfig, + cardWideConfig: CardWideConfig, + ): Promise { + this._cameraManager = new CameraManager( + new CameraManagerEngineFactory( + this._entityRegistryManager, + this._resolvedMediaCache, + cardWideConfig, + ), + this._cardWideConfig, + ); + + // For each camera merge the config (which has no defaults) into the camera + // global config (which does have defaults). The merging must happen in this + // order, to ensure that the defaults in the cameras global config do not + // override the values specified in the per-camera config. + const cameras = config.cameras.map((camera) => + merge(cloneDeep(config.cameras_global), camera), + ); + + try { + await this._cameraManager.initializeCameras( + hass, + this._entityRegistryManager, + cameras, + ); + } catch (e: unknown) { + this._handleThrownError(e); + } + + // If there's no view set yet, set one. This will be the case on initial camera load. + if (!this._view) { + // Don't reset the message which may be set to an error above. This sets the + // first view using the newly loaded cameras. + this._changeView({ resetMessage: false }); + } + } + + protected async _initializeMediaPlayers(hass: HomeAssistant): Promise { + const isValidMediaPlayer = (entityID: string): boolean => { + if (entityID.startsWith('media_player.')) { + const stateObj = this._hass?.states[entityID]; + 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, + ); + let mediaPlayerEntities: Map; + try { + mediaPlayerEntities = await this._entityRegistryManager.getEntities( + hass, + mediaPlayers, + ); + } catch (e) { + // Failing to fetch media player information is not considered + // sufficiently serious to block card startup. + errorToConsole(e as Error); + return; + } + + // Filter out entities that are marked as hidden (this information is not + // available in the HA state, only in the registry). + this._mediaPlayers = mediaPlayers.filter((entityID) => { + // Specifically allow for media players that are not found in the entity registry: + // See: https://github.com/dermotduffy/frigate-hass-card/issues/1016 + const entity = mediaPlayerEntities.get(entityID); + return !entity || !entity.hidden_by; + }); + } + + /** + * Initialize the hard requirements for rendering anything. + * @returns `true` if card rendering can continue. + */ + protected _initializeMandatory(): boolean { + if ( + this._initializer.isInitializedMultiple([ + InitializationAspect.LANGUAGES, + InitializationAspect.SIDE_LOAD_ELEMENTS, + InitializationAspect.CAMERAS, + ]) + ) { + return true; + } + + const hass = this._hass; + const config = this._getConfig(); + const cardWideConfig = this._cardWideConfig; + if (!hass || !config || !cardWideConfig) { + return false; + } + + this._initializer + .initializeMultipleIfNecessary({ + // Caution: Ensure nothing in this set of initializers requires + // languages since they will not yet have been initialized. + [InitializationAspect.LANGUAGES]: async () => await loadLanguages(hass), + [InitializationAspect.SIDE_LOAD_ELEMENTS]: async () => + await sideLoadHomeAssistantElements(), + }) + .then((initialized) => { + if (!initialized) { + return false; + } + return this._initializer.initializeIfNecessary( + InitializationAspect.CAMERAS, + async () => await this._initializeCameras(hass, config, cardWideConfig), + ); + }) + .then((initialized) => { + if (initialized) { + return this.requestUpdate(); + } + }); + return false; + } + + /** + * Initialize aspects of the card that can load in the 'background'. + * @returns `true` if card rendering can continue. + */ + protected _initializeBackground(): void { + const hass = this._hass; + const config = this._getConfig(); + const needMediaPlayers = config.menu.buttons.media_player.enabled; + if (!hass || !config || !needMediaPlayers) { + return; + } + + if (!this._initializer.isInitialized(InitializationAspect.MEDIA_PLAYERS)) { + return; + } + + this._initializer + .initializeMultipleIfNecessary({ + [InitializationAspect.MEDIA_PLAYERS]: async () => + await this._initializeMediaPlayers(hass), + }) + .then((initialized) => { + if (initialized) { + this.requestUpdate(); + } + }); + return; + } + /** * Determine whether the element should be updated. * @param changedProps The changed properties if any. * @returns `true` if the element should be updated. */ protected shouldUpdate(changedProps: PropertyValues): boolean { + if (!this._initializeMandatory()) { + return false; + } const oldHass = changedProps.get('_hass') as HomeAssistant | undefined; let shouldUpdate = !oldHass || changedProps.size != 1; @@ -1186,11 +1298,13 @@ export class FrigateCard extends LitElement { this._changeView(); shouldUpdate = true; } else { - shouldUpdate ||= isHassDifferent( - this._hass, - oldHass, - this._getConfig().view.render_entities || [], - ); + shouldUpdate ||= isHassDifferent(this._hass, oldHass, [ + ...(this._getConfig().view.render_entities ?? []), + + // Refresh the card if media player state changes: + // https://github.com/dermotduffy/frigate-hass-card/issues/881 + ...(this._mediaPlayers ?? []), + ]); } } return shouldUpdate; @@ -1200,79 +1314,15 @@ export class FrigateCard extends LitElement { * Download media being displayed in the viewer. */ protected async _downloadViewerMedia(): Promise { - if (!this._hass || !(this._view?.isViewerView() || this._view?.is('timeline'))) { - // Should not occur. + const media = this._view?.queryResults?.getSelectedResult(); + if (!this._hass || !this._cameraManager || !media) { return; } - 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 = getEventID(this._view.media); - if (!event_id) { - this._setMessageAndUpdate({ - message: localize('error.download_no_event_id'), - type: 'error', - }); - return; - } - - const cameraConfig = this._getSelectedCameraConfig(); - if (!cameraConfig) { - return; - } - - const path = - `/api/frigate/${cameraConfig.frigate.client_id}` + - `/notifications/${event_id}/` + - `${ - 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) { - errorToConsole(e as Error); - } - - if (!response) { - this._setMessageAndUpdate({ - message: localize('error.download_sign_failed'), - type: 'error', - }); - return; - } - - if ( - navigator.userAgent.startsWith('Home Assistant/') || - navigator.userAgent.startsWith('HomeAssistant/') - ) { - // Home Assistant companion apps cannot download files without opening a - // new browser window. - // - // User-agents are specified here: - // - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107 - // - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75 - window.open(response, '_blank'); - } else { - // Use the HTML5 download attribute to prevent a new window from - // temporarily opening. - const link = document.createElement('a'); - link.setAttribute('download', ''); - link.href = response; - link.click(); - link.remove(); + await downloadMedia(this._hass, this._cameraManager, media); + } catch (error) { + this._handleThrownError(error); } } @@ -1283,30 +1333,42 @@ export class FrigateCard extends LitElement { * @returns */ protected _mediaPlayerAction(mediaPlayer: string, action: 'play' | 'stop'): void { - if (!['play', 'stop'].includes(action)) { + if ( + !['play', 'stop'].includes(action) || + !this._view || + !this._hass || + !this._cameraManager + ) { return; } - let media_content_id: string; - let media_content_type: string; - const extra = {}; - const cameraConfig = this._getSelectedCameraConfig(); - const cameraEntity = cameraConfig?.camera_entity ?? null; + let media_content_id: string | null = null; + let media_content_type: string | null = null; + let title: string | null = null; + let thumbnail: string | null = 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; + const cameraConfig = this._getSelectedCameraConfig(); + if (!cameraConfig) { + return; + } + const cameraEntity = cameraConfig.camera_entity ?? null; + const media = this._view.queryResults?.getSelectedResult(); + + if (this._view.isViewerView() && media) { + media_content_id = media.getContentID(); + media_content_type = media.getContentType(); + title = media.getTitle(); + thumbnail = media.getThumbnail(); } 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 { + title = + this._cameraManager.getCameraMetadata(this._hass, this._view.camera)?.title ?? + null; + thumbnail = this._hass?.states[cameraEntity]?.attributes?.entity_picture ?? null; + } + + if (!media_content_id || !media_content_type) { return; } @@ -1315,7 +1377,10 @@ export class FrigateCard extends LitElement { entity_id: mediaPlayer, media_content_id: media_content_id, media_content_type: media_content_type, - extra: extra, + extra: { + ...(title && { title: title }), + ...(thumbnail && { thumb: thumbnail }), + }, }); } else if (action === 'stop') { this._hass?.callService('media_player', 'media_stop', { @@ -1324,15 +1389,26 @@ export class FrigateCard extends LitElement { } } - /** - * Handle a request for a card action. - * @param ev The action requested. - */ - protected _cardActionHandler(ev: CustomEvent): void { + protected _cardActionEventHandler(ev: CustomEvent): void { const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); - if (!frigateCardAction) { + if (frigateCardAction) { + this._cardActionHandler(frigateCardAction); + } + } + + protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void { + if (!this._view) { return; } + + if ( + frigateCardAction.card_id && + this._getConfig().card_id !== frigateCardAction.card_id + ) { + // Command not intended for this card (e.g. query string command). + return; + } + const action = frigateCardAction.frigate_card_action; switch (action) { @@ -1343,31 +1419,32 @@ export class FrigateCard extends LitElement { case 'clips': case 'image': case 'live': + case 'recording': + case 'recordings': case 'snapshot': case 'snapshots': case 'timeline': - if (this._view) { - this._changeView({ - view: new View({ - view: action, - camera: this._view.camera, - }), - }); - } + this._changeView({ + view: new View({ + view: action, + camera: this._view.camera, + }), + }); break; case 'download': this._downloadViewerMedia(); break; - case 'frigate_ui': - const frigate_url = this._getFrigateURLFromContext(); - if (frigate_url) { - window.open(frigate_url); + case 'camera_ui': + const url = this._getCameraURLFromContext(); + if (url) { + window.open(url); } break; + case 'expand': + this._setExpand(!this._expand); + break; case 'fullscreen': - if (screenfull.isEnabled) { - screenfull.toggle(this); - } + this._toggleFullscreen(); break; case 'menu_toggle': // This is a rare code path: this would only be used if someone has a @@ -1376,24 +1453,32 @@ export class FrigateCard extends LitElement { 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), - ); + const selectCameraID = frigateCardAction.camera; + if ( + this._view && + this._cameraManager?.getStore().hasVisibleCameraID(selectCameraID) + ) { + const viewOnCameraSelect = this._getConfig().view.camera_select; + const targetView = + viewOnCameraSelect === 'current' ? this._view.view : viewOnCameraSelect; + const actualView = this.isViewSupportedByCamera(selectCameraID, targetView) + ? targetView + : FRIGATE_CARD_VIEW_DEFAULT; this._changeView({ - view: new View({ - view: this._cameras?.get(camera)?.frigate.camera_name - ? targetView - : // Fallback to supported views for non-Frigate cameras. - View.selectBestViewForNonFrigateCameras(targetView), - camera: camera, - }), + view: new View({ view: actualView, camera: selectCameraID }), }); } break; + case 'live_substream_select': + const overrides: Map = + this._view.context?.live?.overrides ?? new Map(); + overrides.set(this._view.camera, frigateCardAction.camera); + this._changeView({ + view: this._view.clone().mergeInContext({ + live: { overrides: overrides }, + }), + }); + break; case 'media_player': this._mediaPlayerAction( frigateCardAction.media_player, @@ -1408,6 +1493,33 @@ export class FrigateCard extends LitElement { } } + public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean { + const capabilities = this._cameraManager?.getCameraCapabilities(cameraID); + switch (view) { + case 'live': + case 'image': + return true; + case 'clip': + case 'clips': + return !!capabilities?.supportsClips; + case 'snapshot': + case 'snapshots': + return !!capabilities?.supportsSnapshots; + case 'recording': + case 'recordings': + return !!capabilities?.supportsRecordings; + case 'timeline': + return !!capabilities?.supportsTimeline; + case 'media': + return ( + !!capabilities?.supportsClips || + !!capabilities?.supportsSnapshots || + !!capabilities?.supportsRecordings + ); + } + return false; + } + /** * Generate diagnostics for issue reports. */ @@ -1445,6 +1557,7 @@ export class FrigateCard extends LitElement { date: new Date(), frigate_version: Object.fromEntries(frigateVersionMap), lang: getLanguage(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, git: { ...(pkg['gitVersion'] && { build_version: pkg['gitVersion'] }), ...(pkg['buildDate'] && { build_date: pkg['buildDate'] }), @@ -1460,18 +1573,19 @@ export class FrigateCard extends LitElement { * 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) { + protected _getCameraURLFromContext(): string | null { + if (!this._view) { return null; } - if (!cameraConfig.frigate.camera_name) { - return cameraConfig.frigate.url; - } - if (this._view.isViewerView() || this._view.isGalleryView()) { - return `${cameraConfig.frigate.url}/events?camera=${cameraConfig.frigate.camera_name}`; - } - return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`; + + const selectedCameraID = this._view.camera; + const media = this._view.queryResults?.getSelectedResult() ?? null; + const endpoints = + this._cameraManager?.getCameraEndpoints(selectedCameraID, { + view: this._view.view, + ...(media && { media: media }), + }) ?? null; + return endpoints?.ui?.endpoint ?? null; } /** @@ -1585,6 +1699,7 @@ export class FrigateCard extends LitElement { .hass=${this._hass} .menuConfig=${this._getConfig().menu} .buttons=${this._getMenuButtons()} + .entityRegistryManager=${this._entityRegistryManager} > `; } @@ -1643,13 +1758,39 @@ export class FrigateCard extends LitElement { return; } + log(this._cardWideConfig, `Frigate Card media load: `, mediaLoadedInfo); + this._lastValidMediaLoadedInfo = this._currentMediaLoadedInfo = mediaLoadedInfo; + this._setPropertiesForExpandedMode(); + // An update may be required to draw elements. this._generateConditionState(); this.requestUpdate(); } + protected _setPropertiesForExpandedMode(): void { + // When a new media loads, set the aspect ratio for when the card is + // expanded/popped-up. This is based exclusively on last media content, + // as dimension configuration does not apply in fullscreen or expanded mode. + this.style.setProperty( + '--frigate-card-expand-aspect-ratio', + this._view?.isAnyMediaView() && this._lastValidMediaLoadedInfo + ? `${this._lastValidMediaLoadedInfo.width} / ${this._lastValidMediaLoadedInfo.height}` + : 'unset', + ); + // Non-media mays have no intrinsic dimensions and so we need to explicit + // request the dialog to use all available space. + this.style.setProperty( + '--frigate-card-expand-width', + this._view?.isAnyMediaView() ? 'none' : 'var(--frigate-card-expand-max-width)', + ); + this.style.setProperty( + '--frigate-card-expand-height', + this._view?.isAnyMediaView() ? 'none' : 'var(--frigate-card-expand-max-height)', + ); + } + /** * Unload a media item. */ @@ -1678,6 +1819,7 @@ export class FrigateCard extends LitElement { screenfull.on('change', this._fullscreenHandler.bind(this)); } this.addEventListener('mousemove', this._boundMouseHandler); + this._panel = isCardInPanel(this); } /** @@ -1711,12 +1853,14 @@ export class FrigateCard extends LitElement { // Do not artifically constrain aspect ratio if: // - It's fullscreen. + // - It's in expanded mode. // - Aspect ratio enforcement is disabled. // - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the // gallery) or timeline. return !( (screenfull.isEnabled && screenfull.isFullscreen) || + this._expand || aspectRatioMode == 'unconstrained' || (aspectRatioMode == 'dynamic' && (this._view?.isAnyMediaView() || this._view?.is('timeline'))) @@ -1734,7 +1878,8 @@ export class FrigateCard extends LitElement { } const aspectRatioMode = this._getConfig().dimensions.aspect_ratio_mode; - if (aspectRatioMode == 'dynamic' && this._lastValidMediaLoadedInfo) { + + if (this._lastValidMediaLoadedInfo && aspectRatioMode === 'dynamic') { return `${this._lastValidMediaLoadedInfo.width} / ${this._lastValidMediaLoadedInfo.height}`; } @@ -1765,7 +1910,7 @@ export class FrigateCard extends LitElement { if (this._view?.is('live')) { specificActions = this._getConfig().live.actions; } else if (this._view?.isGalleryView()) { - specificActions = this._getConfig().event_gallery?.actions; + specificActions = this._getConfig().media_gallery?.actions; } else if (this._view?.isViewerView()) { specificActions = this._getConfig().media_viewer.actions; } else if (this._view?.is('image')) { @@ -1774,6 +1919,40 @@ export class FrigateCard extends LitElement { return { ...this._getConfig().view.actions, ...specificActions }; } + protected _setExpand(expand: boolean): void { + if (screenfull.isEnabled && screenfull.isFullscreen) { + // Fullscreen and expanded mode are mutually exclusive. + screenfull.exit(); + } + + this._expand = expand; + this._generateConditionState(); + } + + protected _toggleFullscreen(): void { + if (screenfull.isEnabled) { + // Fullscreen and expanded mode are mutually exclusive. + this._expand = false; + screenfull.toggle(this); + } + } + + protected _renderInDialogIfNecessary(contents: TemplateResult): TemplateResult | void { + if (this._expand) { + return html` { + this._setExpand(false); + }} + > + ${contents} + `; + } else { + return contents; + } + } + /** * Master render method for the card. */ @@ -1807,8 +1986,7 @@ export class FrigateCard extends LitElement { // 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)} + @ll-custom=${this._cardActionEventHandler.bind(this)} @frigate-card:message=${this._messageHandler.bind(this)} @frigate-card:view:change=${this._changeViewHandler.bind(this)} @frigate-card:view:change-context=${this._addViewContextHandler.bind(this)} @@ -1827,23 +2005,25 @@ export class FrigateCard extends LitElement { > ${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(), - ) + ${!this._cameraManager?.isInitialized() && !this._message + ? renderProgressIndicator({ cardWideConfig: this._cardWideConfig }) : // Always want to call render even if there's a message, to // ensure live preload is always present (even if not displayed). - this._render()} + html``} ${ - // Keep message rendering to last to show messages that may have - // been generated during the render. + // Keep message rendering to last to show messages that may have been + // generated during the render. this._message ? renderMessage(this._message) : '' }
@@ -1868,90 +2048,12 @@ export class FrigateCard extends LitElement { > ` : ``} -
`; + `); } - /** - * Sub-render method for the card. - */ - protected _render(): TemplateResult | void { - const cameraConfig = this._getSelectedCameraConfig(); - - if (!this._hass || !this._view || !cameraConfig) { - return html``; - } - - // Render but hide the live view if there's a message, or if it's preload - // mode and the view is not live. - const liveClasses = { - hidden: - !!this._message || (this._getConfig().live.preload && !this._view.is('live')), - }; - - return html` - ${!this._message && this._view.is('image') - ? html` - ` - : ``} - ${!this._message && this._view.isGalleryView() - ? html` - ` - : ``} - ${!this._message && this._view.isViewerView() - ? 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. - - // Note: uses the underlying _config rather than the - // overriden config (via getConfig), as it does it's own overriding as - // part of the camera carousel. - this._getConfig().live.preload || (!this._message && this._view.is('live')) - ? html` - - - ` - : `` - } - `; + protected firstUpdated(): void { + // Execute query string actions after first render is complete. + getActionsFromQueryString().forEach((action) => this._cardActionHandler(action)); } /** diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 4a018780..351bf339 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -15,7 +15,7 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { throttle } from 'lodash-es'; +import throttle from 'lodash-es/throttle'; import carouselStyle from '../scss/carousel.scss'; import { TransitionEffect } from '../types'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; @@ -41,15 +41,12 @@ export class FrigateCardCarousel extends LitElement { @property({ attribute: false }) public carouselPlugins?: EmblaCarouselPlugins; + @property({ attribute: false }) + public selected = 0; + @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; @@ -81,7 +78,7 @@ export class FrigateCardCarousel extends LitElement { // 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 }); + this._destroyCarousel(); super.disconnectedCallback(); } @@ -96,32 +93,10 @@ export class FrigateCardCarousel extends LitElement { 'carouselPlugins', ] as const; if (destroyProperties.some((prop) => changedProps.has(prop))) { - this._destroyCarousel({ savePosition: true }); + this._destroyCarousel(); } } - /** - * Scroll to a particular slide. - * @param index Slide number. - */ - 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 A CarouselSelect object (index & element). @@ -139,13 +114,6 @@ export class FrigateCardCarousel extends LitElement { return null; } - /** - * Get the carousel. - */ - public carouselClickAllowed(): boolean { - return this._carousel?.clickAllowed() ?? true; - } - /** * Get the carousel. */ @@ -153,25 +121,21 @@ export class FrigateCardCarousel extends LitElement { 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(); + const 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 }); + }); + }; - this._carouselReInit({ - ...(selected && { startIndex: selected.index }), + carouselReInit({ + startIndex: this.selected, }); } @@ -204,6 +168,10 @@ export class FrigateCardCarousel extends LitElement { if (!this._carousel) { this._initCarousel(); } + + if (changedProperties.has('selected')) { + this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none'); + } } /** @@ -211,9 +179,7 @@ export class FrigateCardCarousel extends LitElement { * @param options If `savePosition` is set the existing carousel position * will be saved so it can be restored if the carousel is recreated. */ - protected _destroyCarousel(options?: { savePosition: boolean }): void { - this._savedStartIndex = - (options?.savePosition ? this._carousel?.selectedScrollSnap() : null) ?? null; + protected _destroyCarousel(): void { if (this._carousel) { this._carousel.destroy(); } @@ -240,14 +206,13 @@ export class FrigateCardCarousel extends LitElement { nodes, { axis: this.direction == 'horizontal' ? 'x' : 'y', - speed: 20, + speed: 30, + startIndex: this.selected, ...this.carouselOptions, - ...(this._savedStartIndex && { startIndex: this._savedStartIndex }), }, this.carouselPlugins, ); - this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); - this._carousel.on('select', () => { + const selectSlide = (): void => { const selected = this.getCarouselSelected(); if (selected) { dispatchFrigateCardEvent(this, 'carousel:select', selected); @@ -256,8 +221,10 @@ export class FrigateCardCarousel extends LitElement { // Make sure every select causes a refresh to allow for re-paint of the // next/previous controls. this.requestUpdate(); - }); + }; + this._carousel.on('init', selectSlide); + this._carousel.on('select', selectSlide); this._carousel.on('scroll', () => { this._scrolling = true; }); @@ -286,18 +253,14 @@ export class FrigateCardCarousel extends LitElement { 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._destroyCarousel(); 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; + const showPrevious = this.carouselOptions?.loop || this.selected > 0; + const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length; return html`
${showPrevious ? html`` : ``} diff --git a/src/components/date-picker.ts b/src/components/date-picker.ts new file mode 100644 index 00000000..36cdd277 --- /dev/null +++ b/src/components/date-picker.ts @@ -0,0 +1,44 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import 'lit-flatpickr'; +import { LitFlatpickr } from 'lit-flatpickr'; +import { customElement } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import datePickerStyle from '../scss/date-picker.scss'; +import { dispatchFrigateCardEvent } from '../utils/basic'; + +export interface DatePickerEvent { + date: Date; +} + +@customElement('frigate-card-date-picker') +export class FrigateCardDatePicker extends LitElement { + protected _refInput: Ref = createRef(); + + public open(): void { + this._refInput.value?.open(); + } + + protected render(): TemplateResult { + return html` { + if (dates.length) { + // This is a single date picker, there should be only a single date. + dispatchFrigateCardEvent(this, 'date-picker:change', { + date: dates[0], + }); + } + }} + >`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(datePickerStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-date-picker': FrigateCardDatePicker; + } +} diff --git a/src/components/drawer.ts b/src/components/drawer.ts index dfff296a..0639c707 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -15,6 +15,11 @@ import drawerStyle from '../scss/drawer.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { isHoverableDevice } from '../utils/basic'; +export interface DrawerIcons { + open?: string; + closed?: string; +} + @customElement('frigate-card-drawer') export class FrigateCardDrawer extends LitElement { @property({ attribute: true, reflect: true }) @@ -26,6 +31,9 @@ export class FrigateCardDrawer extends LitElement { @property({ type: Boolean, reflect: true, attribute: true }) public open = false; + @property({ attribute: false }) + public icons?: DrawerIcons; + // 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 @@ -111,7 +119,9 @@ export class FrigateCardDrawer extends LitElement { > { // Only open the drawer on mousenter when the device // supports hover (otherwise iOS may end up passing on @@ -126,7 +136,7 @@ export class FrigateCardDrawer extends LitElement {
` : ''} - + this._slotChanged()}> `; } diff --git a/src/components/embla-plugins/automedia.ts b/src/components/embla-plugins/automedia.ts index 078eac90..a4f3dbd6 100644 --- a/src/components/embla-plugins/automedia.ts +++ b/src/components/embla-plugins/automedia.ts @@ -27,7 +27,7 @@ const defaultOptions: OptionsType = { breakpoints: {}, }; -export type AutoMediaOptionsType = Partial +type AutoMediaOptionsType = Partial export type AutoMediaType = CreatePluginType< { diff --git a/src/components/embla-plugins/lazyload.ts b/src/components/embla-plugins/lazyload.ts index 3fbdb9f4..311ee6cc 100644 --- a/src/components/embla-plugins/lazyload.ts +++ b/src/components/embla-plugins/lazyload.ts @@ -3,7 +3,7 @@ import { CreatePluginType } from 'embla-carousel/components/Plugins'; import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; import { LazyUnloadCondition } from '../../types'; -export type OptionsType = CreateOptionsType<{ +type OptionsType = CreateOptionsType<{ // Number of slides to lazyload left/right of selected (0 == only selected // slide). lazyLoadCount?: number; @@ -13,15 +13,15 @@ export type OptionsType = CreateOptionsType<{ lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; }>; -export const defaultOptions: OptionsType = { +const defaultOptions: OptionsType = { active: true, breakpoints: {}, lazyLoadCount: 0, }; -export type LazyloadOptionsType = Partial; +type LazyloadOptionsType = Partial; -export type LazyloadType = CreatePluginType< +type LazyloadType = CreatePluginType< { hasLazyloaded(index: number): boolean; }, diff --git a/src/components/gallery.ts b/src/components/gallery.ts index d0391272..c331be0d 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -1,6 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { - css, CSSResultGroup, html, LitElement, @@ -8,10 +6,11 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import galleryStyle from '../scss/gallery.scss'; +import galleryCoreStyle from '../scss/gallery-core.scss'; import { - CameraConfig, + CardWideConfig, ExtendedHomeAssistant, frigateCardConfigDefaults, GalleryConfig, @@ -19,14 +18,33 @@ import { } from '../types.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'; + changeViewToRecentEventsForCameraAndDependents, + changeViewToRecentRecordingForCameraAndDependents, +} from '../utils/media-to-view.js'; +import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js'; +import { View } from '../view/view.js'; +import { renderMessage, renderProgressIndicator } from './message.js'; import './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types'; +import { MediaQueriesResults } from '../view/media-queries-results'; +import { errorToConsole, sleep } from '../utils/basic'; +import './media-filter'; +import './surround-basic'; +import { ViewMedia } from '../view/media'; +import { localize } from '../localize/localize'; +import throttle from 'lodash-es/throttle'; +import { classMap } from 'lit/directives/class-map.js'; + +const GALLERY_MEDIA_FILTER_MENU_ICONS = { + closed: 'mdi:filter-cog-outline', + open: 'mdi:filter-cog', +}; + +const MIN_GALLERY_EXTENSION_SECONDS = 0.5; @customElement('frigate-card-gallery') export class FrigateCardGallery extends LitElement { @@ -40,69 +58,88 @@ export class FrigateCardGallery extends LitElement { public galleryConfig?: GalleryConfig; @property({ attribute: false }) - public cameras?: Map; + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - const mediaType = this.view?.getMediaType(); if ( !this.hass || !this.view || - !this.cameras || !this.view.isGalleryView() || - !mediaType + !this.cameraManager || + !this.cardWideConfig ) { return; } - if (!this.view.target) { - const browseMediaQueryParameters = - getFullDependentBrowseMediaQueryParametersOrDispatchError( + if (!this.view.query) { + if (this.view.is('recordings')) { + changeViewToRecentRecordingForCameraAndDependents( this, this.hass, - this.cameras, - this.view.camera, - mediaType, + this.cameraManager, + this.cardWideConfig, + this.view, + ); + } else { + const mediaType = this.view.is('snapshots') + ? 'snapshots' + : this.view.is('clips') + ? 'clips' + : null; + changeViewToRecentEventsForCameraAndDependents( + this, + this.hass, + this.cameraManager, + this.cardWideConfig, + this.view, + { + ...(mediaType && { mediaType: mediaType }), + }, ); - - if (!browseMediaQueryParameters) { - return; } - - fetchLatestMediaAndDispatchViewChange( - this, - this.hass, - this.view, - browseMediaQueryParameters, - ); - return renderProgressIndicator(); + return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); } return html` - - + ${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none' + ? html` + ` + : ''} + + + `; } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { - return css` - :host { - display: block; - width: 100%; - height: 100%; - } - `; + return unsafeCSS(galleryStyle); } } @@ -118,13 +155,112 @@ export class FrigateCardGalleryCore extends LitElement { public galleryConfig?: GalleryConfig; @property({ attribute: false }) - public cameras?: Map; + public cameraManager?: CameraManager; + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + protected _intersectionObserver: IntersectionObserver; protected _resizeObserver: ResizeObserver; + protected _refLoaderBottom: Ref = createRef(); + protected _refSelected: Ref = createRef(); + + // Bottom loader: A progress indicator shown in a "cell" (not across) at the + // bottom of the gallery. Once visible this attempts to fetch new content from + // "earlier" (less recently) than the current query. This is rendered by + // default (and once visible, the fetch is triggered after which it is + // re-hidden). + @state() + protected _showLoaderBottom = true; + + // Top loader: A progress indicator is shown across the top of the gallery if + // the user is _already_ at the top of the gallery and scrolls upwards. This + // attempts to fetch new content from "later" (more recently) than the current + // query. This is hidden by default. + @state() + protected _showLoaderTop = false; + + protected _media?: ViewMedia[]; + + protected _boundWheelHandler = this._wheelHandler.bind(this); + protected _boundTouchStartHandler = this._touchStartHandler.bind(this); + protected _boundTouchEndHandler = this._touchEndHandler.bind(this); + + // Wheel / touch events may be voluminous, throttle extension calls. + protected _throttleExtendGalleryLater = throttle( + this._extendGallery.bind(this), + MIN_GALLERY_EXTENSION_SECONDS * 1000, + { + leading: true, + trailing: false, + }, + ); + + protected _touchScrollYPosition: number | null = null; constructor() { super(); this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); + this._intersectionObserver = new IntersectionObserver( + this._intersectionHandler.bind(this), + ); + } + + // Since the scroll event does not fire if the user is already at the top of + // the container, instead we manually use the wheel and touchstart/end events + // to detect "top upwards scrolling" (to trigger an extension of the gallery). + + protected _touchStartHandler(ev: TouchEvent): void { + // Remember the Y touch position on touch start, so that we can calculate if + // the user gestured upwards or downards on touchend. + if (ev.touches.length === 1) { + this._touchScrollYPosition = ev.touches[0].screenY; + } else { + this._touchScrollYPosition = null; + } + } + + protected async _touchEndHandler(ev: TouchEvent): Promise { + if ( + !this.scrollTop && + ev.changedTouches.length === 1 && + this._touchScrollYPosition + ) { + if (ev.changedTouches[0].screenY > this._touchScrollYPosition) { + await this._extendLater(); + } + } + this._touchScrollYPosition = null; + } + + protected async _wheelHandler(ev: WheelEvent): Promise { + if (!this.scrollTop && ev.deltaY < 0) { + await this._extendLater(); + } + } + + protected async _extendLater(): Promise { + const start = new Date(); + this._showLoaderTop = true; + await this._throttleExtendGalleryLater( + 'later', + // Ask the engine to avoid use of cache since the user is explicitly + // looking for the freshest possible data. + false, + ); + const delta = new Date().getTime() - start.getTime(); + if (delta < MIN_GALLERY_EXTENSION_SECONDS * 1000) { + // Hidden gem: "legitimate" (?!) use of sleep() :-) + // These calls can return very quickly even with caching disabled since + // the time window constraints on the query will usually be very narrow + // and the backend can thus very quickly reply. It's often so fast it + // actually looks like a rendering issue where the progress indictor + // barely registers before it's gone again. This optional pause ensures + // there is at least some visual feedback to the user that last long + // enough they can 'feel' the fetch has happened. + await sleep(MIN_GALLERY_EXTENSION_SECONDS - delta / 1000); + } + this._showLoaderTop = false; } /** @@ -133,13 +269,24 @@ export class FrigateCardGalleryCore extends LitElement { connectedCallback(): void { super.connectedCallback(); this._resizeObserver.observe(this); + this.addEventListener('wheel', this._boundWheelHandler, { passive: true }); + this.addEventListener('touchstart', this._boundTouchStartHandler, { passive: true }); + this.addEventListener('touchend', this._boundTouchEndHandler); + + // Request update in order to ensure the intersection observer reconnects + // with the loader sentinel. + this.requestUpdate(); } /** * Component disconnected callback. */ disconnectedCallback(): void { + this.removeEventListener('wheel', this._boundWheelHandler); + this.removeEventListener('touchstart', this._boundTouchStartHandler); + this.removeEventListener('touchend', this._boundTouchEndHandler); this._resizeObserver.disconnect(); + this._intersectionObserver.disconnect(); super.disconnectedCallback(); } @@ -149,7 +296,7 @@ export class FrigateCardGalleryCore extends LitElement { protected _setColumnCount(): void { const thumbnailSize = this.galleryConfig?.controls.thumbnails.size ?? - frigateCardConfigDefaults.event_gallery.controls.thumbnails.size; + frigateCardConfigDefaults.media_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( @@ -168,16 +315,66 @@ export class FrigateCardGalleryCore extends LitElement { 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 - ); + protected async _intersectionHandler( + entries: IntersectionObserverEntry[], + ): Promise { + if (entries.every((entry) => !entry.isIntersecting)) { + return; + } + + this._showLoaderBottom = false; + await this._extendGallery('earlier'); + } + + protected async _extendGallery( + direction: 'earlier' | 'later', + useCache = true, + ): Promise { + if (!this.cameraManager || !this.hass || !this.view) { + return; + } + + const query = this.view?.query; + const rawQueries = query?.getQueries() ?? null; + const existingMedia = this.view.queryResults?.getResults(); + if (!query || !rawQueries || !existingMedia) { + return; + } + + let extension: ExtendedMediaQueryResult | null; + try { + extension = await this.cameraManager.extendMediaQueries( + this.hass, + rawQueries, + existingMedia, + direction, + { + useCache: useCache, + }, + ); + } catch (e) { + errorToConsole(e as Error); + return; + } + + if (extension) { + const newMediaQueries = MediaQueriesClassifier.areEventQueries(query) + ? new EventMediaQueries(extension.queries as EventQuery[]) + : MediaQueriesClassifier.areRecordingQueries(query) + ? new RecordingMediaQueries(extension.queries as RecordingQuery[]) + : null; + + if (newMediaQueries) { + this.view + ?.evolve({ + query: newMediaQueries, + queryResults: new MediaQueriesResults(extension.results).selectResultIfFound( + (media) => media === this.view?.queryResults?.getSelectedResult(), + ), + }) + .dispatchChangeEvent(this); + } + } } /** @@ -199,6 +396,21 @@ export class FrigateCardGalleryCore extends LitElement { ); } } + if (changedProps.has('view')) { + // If the view changes, always render the bottom loader to allow for the + // view to be extended once the bottom loader becomes visible. + this._showLoaderBottom = true; + const oldView: View | undefined = changedProps.get('view'); + + if ( + oldView?.queryResults?.getResults() !== this.view?.queryResults?.getResults() + ) { + // Gallery places the most recent media at the top (the query results place + // the most recent media at the end for use in the viewer). This is copied + // to a new array to avoid reversing the query results in place. + this._media = [...(this.view?.queryResults?.getResults() ?? [])].reverse(); + } + } } /** @@ -206,90 +418,106 @@ export class FrigateCardGalleryCore extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - if ( - !this.hass || - !this.view || - !this.view.target || - !this.view.target.children || - !(this.view.is('clips') || this.view.is('snapshots')) || - !this.cameras - ) { + if (!this._media || !this.hass || !this.view || !this.view.isGalleryView()) { return html``; } - const cameraConfig = this.cameras.get(this.view.camera); - return html` - ${this._showBackArrow() - ? html` { - if (this.view && this.view.previous) { - this.view.previous.dispatchChangeEvent(this); + if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) { + // Note that this is not throwing up an error message for the card to + // handle (as typical), but rather directly rendering the message into the + // gallery. This is to allow the filter to still be available when a given + // filter selection returns no media. + return renderMessage({ + type: 'info', + message: localize('common.no_media'), + icon: 'mdi:multimedia', + }); + } + + const selected = this.view?.queryResults?.getSelectedResult(); + return html`
+ ${this._showLoaderTop + ? html`${renderProgressIndicator({ + cardWideConfig: this.cardWideConfig, + classes: { + top: true, + }, + size: 'small', + })}` + : ''} + ${this._media.map( + (media, index) => + html` { + if (this.view && this._media) { + this.view + .evolve({ + view: 'media', + queryResults: this.view.queryResults?.clone().selectResult( + // Media in the gallery is reversed vs the queryResults (see + // note above). + this._media.length - index - 1, + ), + }) + .dispatchChangeEvent(this); } stopEventFromActivatingCardWideActions(ev); }} - outlined="" > - - ` - : ''} - ${this.view.target.children.map( - (child, index) => - html` - ${child.can_expand - ? html` - { - if (this.hass && this.view) { - fetchChildMediaAndDispatchViewChange( - this, - this.hass, - this.view, - child, - ); - } - stopEventFromActivatingCardWideActions(ev); - }} - outlined="" - > -
${child.title}
-
- ` - : child.thumbnail - ? html` { - if (this.view) { - this.view - .evolve({ - view: this.view.is('clips') ? 'clip' : 'snapshot', - childIndex: index, - }) - .dispatchChangeEvent(this); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - ` - : ``} - `, +
`, )} - `; + ${this._showLoaderBottom + ? html`${renderProgressIndicator({ + cardWideConfig: this.cardWideConfig, + componentRef: this._refLoaderBottom, + })}` + : ''} +
`; + } + + public updated(changedProps: PropertyValues): void { + if (this._refLoaderBottom.value) { + this._intersectionObserver.disconnect(); + this._intersectionObserver.observe(this._refLoaderBottom.value); + } + + // This wait for updateComplete is necessary for the scrolling to work + // correctly. + this.updateComplete.then(() => { + // As a special case, if the view has changed and did not previously exist + // (i.e. first setting of it), we intentionally scroll the gallery to the + // selected element in that view (if any). + // See: https://github.com/dermotduffy/frigate-hass-card/issues/885 + if ( + // If this update cycle updated the view ... + changedProps.has('view') && + // ... and it wasn't set at all prior ... + !changedProps.get('view') && + // ... and there is a thumbnail rendered that is selected. + this._refSelected.value + ) { + this._refSelected.value.scrollIntoView(); + } + }); } - /** - * Get styles. - */ static get styles(): CSSResultGroup { - return unsafeCSS(galleryStyle); + return unsafeCSS(galleryCoreStyle); } } diff --git a/src/components/image.ts b/src/components/image.ts index a8f5a743..7f9144c9 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -6,7 +6,7 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS + unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; @@ -15,13 +15,17 @@ import { CachedValueController } from '../cached-value-controller.js'; import defaultImage from '../images/frigate-bird-in-sky.jpg'; import { localize } from '../localize/localize.js'; import imageStyle from '../scss/image.scss'; -import { CameraConfig, ImageViewConfig } from '../types.js'; +import { CameraConfig, ImageViewConfig, MediaLoadedInfo } from '../types.js'; import { isHassDifferent } from '../utils/ha'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; -import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; -import { View } from '../view.js'; +import { + createMediaLoadedInfo, + dispatchExistingMediaLoadedInfoAsEvent, +} from '../utils/media-info.js'; +import { View } from '../view/view.js'; import { dispatchErrorMessageEvent } from './message.js'; import { contentsChanged } from '../utils/basic.js'; +import isEqual from 'lodash-es/isEqual'; // See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py . const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000; @@ -48,6 +52,8 @@ export class FrigateCardImage extends LitElement { protected _cachedValueController?: CachedValueController; protected _boundVisibilityHandler = this._visibilityHandler.bind(this); + protected _mediaLoadedInfo: MediaLoadedInfo | null = null; + /** * Get the camera entity for the current camera configuration. * @returns The entity or undefined if no camera entity is available. @@ -234,7 +240,13 @@ export class FrigateCardImage extends LitElement { ${ref(this._refImage)} src=${live(src)} @load=${(ev: Event) => { - dispatchMediaLoadedEvent(this, ev); + const mediaLoadedInfo = createMediaLoadedInfo(ev); + // Avoid the media being reported as repeatedly loading unless the + // media info changes. + if (mediaLoadedInfo && !isEqual(this._mediaLoadedInfo, mediaLoadedInfo)) { + this._mediaLoadedInfo = mediaLoadedInfo; + dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo); + } }} @error=${() => { if (this.imageConfig?.mode === 'camera') { @@ -246,11 +258,9 @@ export class FrigateCardImage extends LitElement { } else if (this.imageConfig?.mode === 'url') { // In url mode, the user likely specified a URL that cannot be // resolved. Show an error message. - dispatchErrorMessageEvent( - this, - localize('error.image_load_error'), - { context: this.imageConfig }, - ); + dispatchErrorMessageEvent(this, localize('error.image_load_error'), { + context: this.imageConfig, + }); } }} />` @@ -263,7 +273,7 @@ export class FrigateCardImage extends LitElement { } declare global { - interface HTMLElementTagNameMap { - "frigate-card-image": FrigateCardImage - } + interface HTMLElementTagNameMap { + 'frigate-card-image': FrigateCardImage; + } } diff --git a/src/components/live.ts b/src/components/live.ts deleted file mode 100644 index 9b67c980..00000000 --- a/src/components/live.ts +++ /dev/null @@ -1,1343 +0,0 @@ -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, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS, -} from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import { createRef, Ref, ref } from 'lit/directives/ref.js'; -import { guard } from 'lit/directives/guard.js'; -import { keyed } from 'lit/directives/keyed.js'; -import { until } from 'lit/directives/until.js'; -import { ConditionState, getOverriddenConfig } from '../card-condition.js'; -import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; -import { localize } from '../localize/localize.js'; -import liveFrigateStyle from '../scss/live-frigate.scss'; -import liveJSMPEGStyle from '../scss/live-jsmpeg.scss'; -import liveWebRTCStyle from '../scss/live-webrtc.scss'; -import liveStyle from '../scss/live.scss'; -import liveCarouselStyle from '../scss/live-carousel.scss'; -import liveProviderStyle from '../scss/live-provider.scss'; -import { - 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; - -// Number of seconds before the expiry to trigger a refresh. -const URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60; - -@customElement('frigate-card-live') -export class FrigateCardLive extends LitElement { - @property({ attribute: false }) - public conditionState?: ConditionState; - - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public view?: Readonly; - - @property({ attribute: false }) - public cameras?: Map; - - @property({ attribute: false }) - public liveConfig?: LiveConfig; - - @property({ attribute: false, hasChanged: contentsChanged }) - public liveOverrides?: LiveOverrides; - - // Whether or not the live view is currently in the background (i.e. preloaded - // but not visible) - @state() - protected _inBackground?: boolean = true; - - // Intersection handler is used to detect when the live view flips between - // foreground and background (in preload mode). - protected _intersectionObserver: IntersectionObserver; - - // 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), - ); - } - - /** - * Called when the live view intersects with the viewport. - * @param entries The IntersectionObserverEntry entries (should be only 1). - */ - 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(); - } - } - - /** - * Determine whether the element should be updated. - * @param _changedProps The changed properties if any. - * @returns `true` if the element should be updated. - */ - // 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; - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - this._intersectionObserver.observe(this); - super.connectedCallback(); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - super.disconnectedCallback(); - this._intersectionObserver.disconnect(); - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this.hass || !this.liveConfig || !this.cameras || !this.view) { - return; - } - - const config = getOverriddenConfig( - this.liveConfig, - this.liveOverrides, - this.conditionState, - ) as LiveConfig; - - // Does not use getFullDependentBrowseMediaQueryParametersOrDispatchError to - // ensure that non-Frigate cameras will work in live view (they will not - // have a Frigate camera name). - const browseMediaParams = getFullDependentBrowseMediaQueryParameters( - this.hass, - this.cameras, - this.view.camera, - config.controls.thumbnails.media, - ); - - // Notes: - // - See 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). - // - Fetching of thumbnails is disabled as long as live view is the - // background. - // - Various events are captured to prevent them propagating upwards if the - // card is in the background. - // - The entire returned template is keyed to allow for the whole template - // to be re-rendered in certain circumstances (specifically: if a message - // is received when the card is in the background). - const result = html`${keyed( - this._renderKey, - html`) => { - 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(); - } - }} - > - - - `, - )}`; - - this._messageReceivedPostRender = false; - return result; - } - - /** - * Get styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(liveStyle); - } -} - -@customElement('frigate-card-live-carousel') -export class FrigateCardLiveCarousel extends LitElement { - @property({ attribute: false }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public view?: Readonly; - - @property({ attribute: false }) - public cameras?: Map; - - @property({ attribute: false }) - public liveConfig?: LiveConfig; - - @property({ attribute: false, hasChanged: contentsChanged }) - public liveOverrides?: LiveOverrides; - - @property({ attribute: false }) - public inBackground?: boolean; - - @property({ attribute: false }) - 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 { - 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 ( - frigateCardCarousel && - this.view?.camera && - (!oldView || this.view?.camera !== oldView.camera) - ) { - const slide: number | undefined = this._cameraToSlide[this.view.camera]; - if ( - slide !== undefined && - slide !== frigateCardCarousel.getCarouselSelected()?.index - ) { - frigateCardCarousel.carouselScrollTo(slide); - } - } - } - - 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(); - } - } - } - - /** - * Get the transition effect to use. - * @returns An TransitionEffect object. - */ - protected _getTransitionEffect(): TransitionEffect { - return ( - this.liveConfig?.transition_effect ?? - frigateCardConfigDefaults.live.transition_effect - ); - } - - /** - * Get the Embla options to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getOptions(): EmblaOptionsType { - return { - startIndex: - this.cameras && this.view - ? Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera)) - : 0, - draggable: this.liveConfig?.draggable, - loop: true, - }; - } - - /** - * Get the Embla plugins to use. - * @returns A list of EmblaOptionsTypes. - */ - 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({ - ...(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', - ...(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, - }), - }), - ]; - } - - /** - * 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 - * should lazy load, etc. `null` means lazy loading is disabled and everything - * should load simultaneously. - * @returns - */ - protected _getLazyLoadCount(): number | null { - // Defaults to fully-lazy loading. - return this.liveConfig?.lazy_load === false ? null : 0; - } - - /** - * Get slides to include in the render. - * @returns The slides to include in the render and an index keyed by camera - * name to slide number. - */ - protected _getSlides(): [TemplateResult[], Record] { - if (!this.cameras) { - return [[], {}]; - } - - const slides: TemplateResult[] = []; - const cameraToSlide: Record = {}; - - for (const [camera, cameraConfig] of this.cameras) { - const slide = this._renderLive(camera, cameraConfig, slides.length); - if (slide) { - cameraToSlide[camera] = slides.length; - slides.push(slide); - } - } - return [slides, cameraToSlide]; - } - - /** - * Handle the user selecting a new slide in the carousel. - */ - protected _setViewHandler(): void { - const selectedCameraIndex = this._refMediaCarousel.value - ?.frigateCardCarousel() - ?.getCarouselSelected()?.index; - if (selectedCameraIndex === undefined || !this.view || !this.cameras) { - return; - } - - this.view - .evolve({ - 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); - } - - /** - * Lazy load a slide. - * @param _index The slide number to lazy load. - * @param slide The slide to lazy load. - */ - protected _lazyloadOrUnloadSlide( - action: 'load' | 'unload', - _index: number, - slide: Element, - ): void { - 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'; - } - } - - protected _renderLive( - camera: string, - cameraConfig: CameraConfig, - slideIndex: number, - ): TemplateResult | void { - if (!this.liveConfig) { - return; - } - // 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 = { - ...this.conditionState, - camera: camera, - }; - - const config = getOverriddenConfig( - this.liveConfig, - this.liveOverrides, - conditionState, - ) as LiveConfig; - - return html` -
- ) => { - wrapMediaLoadedEventForCarousel(slideIndex, ev); - }} - @frigate-card:media:unloaded=${(ev: CustomEvent) => { - wrapMediaUnloadedEventForCarousel(slideIndex, ev); - }} - > - -
- `; - } - - protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] { - if (!this.cameras || !this.view || !this.hass) { - return [null, null]; - } - const keys = Array.from(this.cameras.keys()); - const currentIndex = keys.indexOf(this.view.camera); - - if (currentIndex < 0 || this.cameras.size <= 1) { - return [null, null]; - } - - const prev = - this.cameras.get( - keys[currentIndex > 0 ? currentIndex - 1 : this.cameras.size - 1], - ) ?? null; - const next = - this.cameras.get( - keys[currentIndex + 1 < this.cameras.size ? currentIndex + 1 : 0], - ) ?? null; - return [prev, next]; - } - - /** - * Render the element. - * @returns A template to display to the user. - */ - protected render(): TemplateResult | void { - const [slides, cameraToSlide] = this._getSlides(); - this._cameraToSlide = cameraToSlide; - if (!slides.length || !this.liveConfig || !this.cameras || !this.view) { - return; - } - - const config = getOverriddenConfig( - this.liveConfig, - this.liveOverrides, - this.conditionState, - ) as LiveConfig; - - 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._refMediaCarousel.value - ?.frigateCardCarousel() - ?.carouselScrollPrevious(); - stopEventFromActivatingCardWideActions(ev); - }} - > - - ${slides} - { - 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 }) - public hass?: ExtendedHomeAssistant; - - @property({ attribute: false }) - public cameraConfig?: CameraConfig; - - @property({ attribute: false }) - 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). - @property({ attribute: true, type: Boolean }) - public disabled = false; - - // Label that is used for ARIA support and as tooltip. - @property({ attribute: false }) - public label = ''; - - @state() - protected _isVideoMediaLoaded = false; - - protected _providerRef: Ref = createRef(); - - /** - * Play the video. - */ - public play(): void { - this._providerRef.value?.play(); - } - - /** - * Pause the video. - */ - public pause(): void { - this._providerRef.value?.pause(); - } - - /** - * Mute the video. - */ - public mute(): void { - this._providerRef.value?.mute(); - } - - /** - * Unmute the video. - */ - public unmute(): void { - this._providerRef.value?.unmute(); - } - - /** - * 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 || - this.cameraConfig?.webrtc_card?.url - ) { - return 'webrtc-card'; - } else if (this.cameraConfig?.camera_entity) { - return 'ha'; - } else if (this.cameraConfig?.frigate.camera_name) { - return 'frigate-jsmpeg'; - } - return frigateCardConfigDefaults.cameras.live_provider; - } - return ( - this.cameraConfig?.live_provider || frigateCardConfigDefaults.cameras.live_provider - ); - } - - /** - * 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. - */ - protected render(): TemplateResult | void { - if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) { - return; - } - - // Set title and ariaLabel from the provided label property. - this.title = this.label; - this.ariaLabel = this.label; - - const provider = this._getResolvedProvider(); - const showImage = !this._isVideoMediaLoaded && this._shouldShowImageDuringLoading(); - const providerClasses = { - hidden: showImage, - }; - - return html` - ${showImage - ? html` - ` - : html``} - ${provider === 'ha' - ? html` - ` - : 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 }) - public hass?: HomeAssistant; - - @property({ attribute: false }) - public cameraConfig?: CameraConfig; - - protected _playerRef: Ref = createRef(); - - /** - * Play the video. - */ - public play(): void { - this._playerRef.value?.play(); - } - - /** - * Pause the video. - */ - public pause(): void { - this._playerRef.value?.pause(); - } - - /** - * Mute the video. - */ - public mute(): void { - this._playerRef.value?.mute(); - } - - /** - * Unmute the video. - */ - public unmute(): void { - this._playerRef.value?.unmute(); - } - - /** - * Seek the video. - */ - public seek(seconds: number): void { - this._playerRef.value?.seek(seconds); - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this.hass) { - return; - } - - if (!this.cameraConfig?.camera_entity) { - return dispatchErrorMessageEvent(this, localize('error.no_live_camera'), { - context: this.cameraConfig, - }); - } - - const stateObj = this.hass.states[this.cameraConfig.camera_entity]; - if (!stateObj) { - return dispatchErrorMessageEvent(this, localize('error.live_camera_not_found'), { - context: this.cameraConfig, - }); - } - - if (stateObj.state === 'unavailable') { - // Don't treat state unavailability as an error per se. - return dispatchMessageEvent( - this, - localize('error.live_camera_unavailable'), - 'info', - { - icon: 'mdi:connection', - context: getCameraTitle(this.hass, this.cameraConfig), - }, - ); - } - - return html` - `; - } - - /** - * Get styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(liveFrigateStyle); - } -} - -// Create a wrapper for AlexxIT's WebRTC card -// - https://github.com/AlexxIT/WebRTC -@customElement('frigate-card-live-webrtc-card') -export class FrigateCardLiveWebRTCCard extends LitElement { - @property({ attribute: false, hasChanged: contentsChanged }) - public webRTCConfig?: WebRTCCardConfig; - - @property({ attribute: false }) - public cameraConfig?: CameraConfig; - - protected hass?: HomeAssistant; - - // A task to await the load of the WebRTC component. - protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]); - - /** - * Play the video. - */ - public play(): void { - this._getPlayer() - ?.play() - .catch(() => { - // WebRTC appears to generate additional spurious load events, which may - // result in loads after a play() call, which causes the browser to spam - // the logs unless the promise rejection is handled here. - }); - } - - /** - * Pause the video. - */ - public pause(): void { - this._getPlayer()?.pause(); - } - - /** - * Mute the video. - */ - public mute(): void { - const player = this._getPlayer(); - if (player) { - player.muted = true; - } - } - - /** - * Unmute the video. - */ - public unmute(): void { - const player = this._getPlayer(); - if (player) { - player.muted = false; - } - } - - /** - * Seek the video. - */ - public seek(seconds: number): void { - const player = this._getPlayer(); - if (player) { - player.currentTime = seconds; - } - } - - /** - * Get the underlying video player. - * @returns The player or `null` if not found. - */ - protected _getPlayer(): HTMLVideoElement | null { - return this.renderRoot?.querySelector('#video') as HTMLVideoElement | null; - } - - protected async _getWebRTCCardElement(): Promise< - CustomElementConstructor | undefined - > { - await customElements.whenDefined('webrtc-camera'); - return customElements.get('webrtc-camera'); - } - - /** - * Create the WebRTC element. May throw. - */ - protected _createWebRTC(): HTMLElement | null { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const webrtcElement = this._webrtcTask.value; - if (webrtcElement && this.hass) { - const webrtc = new webrtcElement() as HTMLElement & { - hass: HomeAssistant; - setConfig: (config: Record) => void; - }; - const config = { ...this.webRTCConfig }; - - // If the live WebRTC configuration does not specify a URL/entity to use, - // then take values from the camera configuration instead (if there are - // any). - if (!config.url) { - config.url = this.cameraConfig?.webrtc_card?.url; - } - if (!config.entity) { - config.entity = this.cameraConfig?.webrtc_card?.entity; - } - webrtc.setConfig(config); - webrtc.hass = this.hass; - return webrtc; - } - return null; - } - - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - const render = (): TemplateResult | void => { - let webrtcElement: HTMLElement | null; - try { - webrtcElement = this._createWebRTC(); - } catch (e) { - return dispatchErrorMessageEvent( - this, - e instanceof FrigateCardError - ? e.message - : localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message, - { context: (e as FrigateCardError).context }, - ); - } - if (webrtcElement) { - // Set the id to ensure that the relevant CSS styles will have - // sufficient specifity to overcome some styles that are otherwise - // applied to in Safari. - webrtcElement.id = 'webrtc'; - } - return html`${webrtcElement}`; - }; - - // Use a task to allow us to asynchronously wait for the WebRTC card to - // load, but yet still have the card load be followed by the updated() - // lifecycle callback (unlike just using `until`). - return renderTask(this, this._webrtcTask, render, () => - renderProgressIndicator(localize('error.webrtc_card_waiting')), - ); - } - - /** - * Updated lifecycle callback. - */ - public updated(): void { - // Extract the video component after it has been rendered and generate the - // media load event. - this.updateComplete.then(() => { - const video = this._getPlayer(); - if (video) { - const onloadeddata = video.onloadeddata; - - video.onloadeddata = (e) => { - if (onloadeddata) { - onloadeddata.call(video, e); - } - dispatchMediaLoadedEvent(this, video); - }; - } - }); - } - - /** - * Get styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(liveWebRTCStyle); - } -} - -@customElement('frigate-card-live-jsmpeg') -export class FrigateCardLiveJSMPEG extends LitElement { - @property({ attribute: false }) - public cameraConfig?: CameraConfig; - - @property({ attribute: false, hasChanged: contentsChanged }) - public jsmpegConfig?: JSMPEGConfig; - - protected hass?: ExtendedHomeAssistant; - - protected _jsmpegCanvasElement?: HTMLCanvasElement; - protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; - protected _refreshPlayerTimerID?: number; - - /** - * Play the video. - */ - public play(): void { - this._jsmpegVideoPlayer?.play(); - } - - /** - * Pause the video. - */ - public pause(): void { - this._jsmpegVideoPlayer?.stop(); - } - - /** - * Mute the video (included for completeness, JSMPEG live disables audio as - * Frigate does not encode it). - */ - public mute(): void { - const player = this._jsmpegVideoPlayer?.player; - if (player) { - player.volume = 0; - } - } - - /** - * Unmute the video (included for completeness, JSMPEG live disables audio as - * Frigate does not encode it). - */ - public unmute(): void { - const player = this._jsmpegVideoPlayer?.player; - if (player) { - player.volume = 1; - } - } - - /** - * Seek the video (unsupported). - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public seek(_seconds: number): void { - // JSMPEG does not support seeking. - } - - /** - * Get a signed player URL. - * @returns A URL or null. - */ - protected async _getURL(): Promise { - if ( - !this.hass || - !this.cameraConfig?.frigate.client_id || - !this.cameraConfig?.frigate.camera_name - ) { - return null; - } - - let response: string | null | undefined; - try { - response = await homeAssistantSignPath( - this.hass, - `/api/frigate/${this.cameraConfig.frigate.client_id}` + - `/jsmpeg/${this.cameraConfig.frigate.camera_name}`, - URL_SIGN_EXPIRY_SECONDS, - ); - } catch (e) { - errorToConsole(e as Error); - return null; - } - if (!response) { - return null; - } - return response.replace(/^http/i, 'ws'); - } - - /** - * Create a JSMPEG player. - * @param url The URL for the player to connect to. - * @returns A JSMPEG player. - */ - protected async _createJSMPEGPlayer(url: string): Promise { - return new Promise((resolve) => { - let videoDecoded = false; - const player = new JSMpeg.VideoElement( - this, - url, - { - canvas: this._jsmpegCanvasElement, - }, - { - // The media carousel may automatically pause when the browser tab is - // inactive, JSMPEG does not need to also do so independently. - pauseWhenHidden: false, - autoplay: false, - protocols: [], - audio: false, - videoBufferSize: 1024 * 1024 * 4, - - // Override with user-specified options. - ...this.jsmpegConfig?.options, - - // Don't allow the player to internally reconnect, as it may re-use a - // URL with a (newly) invalid signature, e.g. during a Home Assistant - // restart. - reconnectInterval: 0, - onVideoDecode: () => { - // This is the only callback that is called after the dimensions - // are available. It's called on every frame decode, so just - // ignore any subsequent calls. - if (!videoDecoded && this._jsmpegCanvasElement) { - videoDecoded = true; - dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement); - resolve(player); - } - }, - }, - ); - }); - } - - /** - * Reset / destroy the player. - */ - protected _resetPlayer(): void { - if (this._refreshPlayerTimerID) { - window.clearTimeout(this._refreshPlayerTimerID); - this._refreshPlayerTimerID = undefined; - } - if (this._jsmpegVideoPlayer) { - try { - this._jsmpegVideoPlayer.destroy(); - } catch (err) { - // Pass. - } - this._jsmpegVideoPlayer = undefined; - } - if (this._jsmpegCanvasElement) { - this._jsmpegCanvasElement.remove(); - this._jsmpegCanvasElement = undefined; - } - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - if (this.isConnected) { - this.requestUpdate(); - } - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - if (!this.isConnected) { - this._resetPlayer(); - } - super.disconnectedCallback(); - } - - /** - * Refresh the JSMPEG player. - */ - protected async _refreshPlayer(): Promise { - this._resetPlayer(); - - this._jsmpegCanvasElement = document.createElement('canvas'); - this._jsmpegCanvasElement.className = 'media'; - - if (!this.cameraConfig?.frigate.camera_name) { - return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), { - context: this.cameraConfig, - }); - } - - const url = await this._getURL(); - if (url) { - this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url); - this._refreshPlayerTimerID = window.setTimeout(() => { - this.requestUpdate(); - }, (URL_SIGN_EXPIRY_SECONDS - URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); - } else { - dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); - } - } - - /** - * Master render method. - */ - protected render(): TemplateResult | void { - const _render = async (): Promise => { - await this._refreshPlayer(); - - if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { - return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); - } - return html`${this._jsmpegCanvasElement}`; - }; - return html`${until(_render(), renderProgressIndicator())}`; - } - - /** - * Get styles. - */ - static get styles(): CSSResultGroup { - return unsafeCSS(liveJSMPEGStyle); - } -} - -declare global { - interface HTMLElementTagNameMap { - 'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG; - 'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard; - 'frigate-card-live-ha': FrigateCardLiveFrigate; - 'frigate-card-live-provider': FrigateCardLiveProvider; - 'frigate-card-live-carousel': FrigateCardLiveCarousel; - 'frigate-card-live': FrigateCardLive; - } -} diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts new file mode 100644 index 00000000..a45264b3 --- /dev/null +++ b/src/components/live/live-go2rtc.ts @@ -0,0 +1,168 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import liveMSEStyle from '../../scss/live-go2rtc.scss'; +import { + CameraConfig, + ExtendedHomeAssistant, + FrigateCardMediaPlayer, +} from '../../types.js'; +import '../image.js'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, +} from '../../utils/media'; +import { dispatchMediaLoadedEvent } from '../../utils/media-info'; +import { localize } from '../../localize/localize'; +import { dispatchErrorMessageEvent } from '../message'; +import { VideoRTC } from '../../external/go2rtc/video-rtc'; +import { CameraEndpoints } from '../../camera-manager/types.js'; +import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; + +// Note (2023-02-18): Depending on the behavior of the player / browser is +// possible this URL will need to be re-signed in order to avoid HA spamming +// logs after the expiry time, but this complexity is not added for now until +// there are verified cases of this being an issue (see equivalent in the JSMPEG +// provider). +const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; + +@customElement('frigate-card-live-go2rtc-player') +class FrigateCardGo2RTCPlayer extends VideoRTC { + public play(): void { + // Let Frigate card control auto playing. + } + + public oninit(): void { + super.oninit(); + + if (this.video) { + const onloadeddata = this.video.onloadeddata; + this.video.onloadeddata = (e) => { + if (onloadeddata) { + onloadeddata.call(this.video, e); + } + hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); + dispatchMediaLoadedEvent(this, this.video); + }; + + // Always started muted. Media may be unmuted in accordance with user + // configuration. + this.video.muted = true; + } + } +} + +@customElement('frigate-card-live-go2rtc') +export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPlayer { + // Not an reactive property to avoid resetting the video. + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + + protected _player?: FrigateCardGo2RTCPlayer; + + public async play(): Promise { + return this._player?.video?.play(); + } + + public async pause(): Promise { + this._player?.video?.pause(); + } + + public async mute(): Promise { + if (this._player?.video) { + this._player.video.muted = true; + } + } + + public async unmute(): Promise { + if (this._player?.video) { + this._player.video.muted = false; + } + } + + public isMuted(): boolean { + return this._player?.video.muted ?? true; + } + + public async seek(seconds: number): Promise { + if (this._player?.video) { + this._player.video.currentTime = seconds; + } + } + + disconnectedCallback(): void { + this._player = undefined; + } + + connectedCallback(): void { + super.connectedCallback(); + + // Reset the player when reconnected to the DOM. + // https://github.com/dermotduffy/frigate-hass-card/issues/996 + this.requestUpdate(); + } + + protected async _createPlayer(): Promise { + if (!this.hass) { + return; + } + + const endpoint = this.cameraEndpoints?.go2rtc; + if (!endpoint) { + return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), { + context: this.cameraConfig, + }); + } + + const address = await getEndpointAddressOrDispatchError( + this, + this.hass, + endpoint, + GO2RTC_URL_SIGN_EXPIRY_SECONDS, + ); + if (!address) { + return; + } + + this._player = new FrigateCardGo2RTCPlayer(); + this._player.src = address; + this._player.visibilityCheck = false; + + if (this.cameraConfig?.go2rtc?.modes && this.cameraConfig.go2rtc.modes.length) { + this._player.mode = this.cameraConfig.go2rtc.modes.join(','); + } + + this.requestUpdate(); + } + + protected willUpdate(changedProps: PropertyValues): void { + if (!this._player || changedProps.has('cameraEndpoints')) { + this._createPlayer(); + } + } + + protected render(): TemplateResult | void { + return html`${this._player}`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(liveMSEStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-go2rtc': FrigateCardGo2RTC; + } +} diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts new file mode 100644 index 00000000..1d8e5aee --- /dev/null +++ b/src/components/live/live-ha.ts @@ -0,0 +1,75 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import liveHAStyle from '../../scss/live-ha.scss'; +import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js'; +import { getStateObjOrDispatchError } from './live.js'; +import '../../patches/ha-camera-stream'; +import '../../patches/ha-hls-player.js'; +import '../../patches/ha-web-rtc-player.ts'; + +@customElement('frigate-card-live-ha') +export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + protected _playerRef: Ref = createRef(); + + public async play(): Promise { + return this._playerRef.value?.play(); + } + + public async pause(): Promise { + this._playerRef.value?.pause(); + } + + public async mute(): Promise { + this._playerRef.value?.mute(); + } + + public async unmute(): Promise { + this._playerRef.value?.unmute(); + } + + public isMuted(): boolean { + return this._playerRef.value?.isMuted() ?? true; + } + + public async seek(seconds: number): Promise { + this._playerRef.value?.seek(seconds); + } + + protected render(): TemplateResult | void { + if (!this.hass) { + return; + } + + const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig); + if (!stateObj) { + return; + } + + return html` + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(liveHAStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-ha': FrigateCardLiveHA; + } +} diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts new file mode 100644 index 00000000..a8e655d2 --- /dev/null +++ b/src/components/live/live-image.ts @@ -0,0 +1,75 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import liveImageStyle from '../../scss/live-image.scss'; +import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js'; +import { getStateObjOrDispatchError } from './live.js'; +import '../image.js'; + +@customElement('frigate-card-live-image') +export class FrigateCardLiveImage extends LitElement implements FrigateCardMediaPlayer { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + @state() + protected _playing = true; + + public async play(): Promise { + this._playing = true; + } + + public async pause(): Promise { + this._playing = false; + } + + public async mute(): Promise { + // Not implemented. + } + + public async unmute(): Promise { + // Not implemented. + } + + public isMuted(): boolean { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async seek(_seconds: number): Promise { + // Not implemented. + } + + protected render(): TemplateResult | void { + if (!this.hass || !this.cameraConfig) { + return; + } + + getStateObjOrDispatchError(this, this.hass, this.cameraConfig); + + return html` + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(liveImageStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-image': FrigateCardLiveImage; + } +} diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts new file mode 100644 index 00000000..7ce52b14 --- /dev/null +++ b/src/components/live/live-jsmpeg.ts @@ -0,0 +1,227 @@ +import JSMpeg from '@cycjimmy/jsmpeg-player'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { until } from 'lit/directives/until.js'; +import { renderProgressIndicator } from '../../components/message.js'; +import { localize } from '../../localize/localize.js'; +import liveJSMPEGStyle from '../../scss/live-jsmpeg.scss'; +import { + CameraConfig, + CardWideConfig, + ExtendedHomeAssistant, + FrigateCardMediaPlayer, +} from '../../types.js'; +import { dispatchMediaLoadedEvent } from '../../utils/media-info.js'; +import { dispatchErrorMessageEvent } from '../message.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; +import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js'; + +// Number of seconds a signed URL is valid for. +const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; + +// Number of seconds before the expiry to trigger a refresh. +const JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS = 1 * 60 * 60; + +@customElement('frigate-card-live-jsmpeg') +export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMediaPlayer { + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + protected hass?: ExtendedHomeAssistant; + + protected _jsmpegCanvasElement?: HTMLCanvasElement; + protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; + protected _refreshPlayerTimerID?: number; + + public async play(): Promise { + return this._jsmpegVideoPlayer?.play(); + } + + public async pause(): Promise { + this._jsmpegVideoPlayer?.stop(); + } + + public async mute(): Promise { + const player = this._jsmpegVideoPlayer?.player; + if (player) { + player.volume = 0; + } + } + + public async unmute(): Promise { + const player = this._jsmpegVideoPlayer?.player; + if (player) { + player.volume = 1; + } + } + + public isMuted(): boolean { + return this._jsmpegVideoPlayer ? this._jsmpegVideoPlayer.player.volume === 0 : true; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async seek(_seconds: number): Promise { + // JSMPEG does not support seeking. + } + + /** + * Create a JSMPEG player. + * @param url The URL for the player to connect to. + * @returns A JSMPEG player. + */ + protected async _createJSMPEGPlayer(url: string): Promise { + return new Promise((resolve) => { + let videoDecoded = false; + const player = new JSMpeg.VideoElement( + this, + url, + { + canvas: this._jsmpegCanvasElement, + }, + { + // The media carousel may automatically pause when the browser tab is + // inactive, JSMPEG does not need to also do so independently. + pauseWhenHidden: false, + autoplay: false, + protocols: [], + audio: false, + videoBufferSize: 1024 * 1024 * 4, + + // Override with user-specified options. + ...this.cameraConfig?.jsmpeg?.options, + + // Don't allow the player to internally reconnect, as it may re-use a + // URL with a (newly) invalid signature, e.g. during a Home Assistant + // restart. + reconnectInterval: 0, + onVideoDecode: () => { + // This is the only callback that is called after the dimensions + // are available. It's called on every frame decode, so just + // ignore any subsequent calls. + if (!videoDecoded && this._jsmpegCanvasElement) { + videoDecoded = true; + dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement); + resolve(player); + } + }, + }, + ); + }); + } + + /** + * Reset / destroy the player. + */ + protected _resetPlayer(): void { + if (this._refreshPlayerTimerID) { + window.clearTimeout(this._refreshPlayerTimerID); + this._refreshPlayerTimerID = undefined; + } + if (this._jsmpegVideoPlayer) { + try { + this._jsmpegVideoPlayer.destroy(); + } catch (err) { + // Pass. + } + this._jsmpegVideoPlayer = undefined; + } + if (this._jsmpegCanvasElement) { + this._jsmpegCanvasElement.remove(); + this._jsmpegCanvasElement = undefined; + } + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + if (this.isConnected) { + this.requestUpdate(); + } + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + if (!this.isConnected) { + this._resetPlayer(); + } + super.disconnectedCallback(); + } + + /** + * Refresh the JSMPEG player. + */ + protected async _refreshPlayer(): Promise { + if (!this.hass) { + return; + } + this._resetPlayer(); + + this._jsmpegCanvasElement = document.createElement('canvas'); + this._jsmpegCanvasElement.className = 'media'; + + const endpoint = this.cameraEndpoints?.jsmpeg; + if (!endpoint) { + return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), { + context: this.cameraConfig, + }); + } + + const address = await getEndpointAddressOrDispatchError( + this, + this.hass, + endpoint, + JSMPEG_URL_SIGN_EXPIRY_SECONDS, + ); + if (!address) { + return; + } + + this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(address); + this._refreshPlayerTimerID = window.setTimeout(() => { + this.requestUpdate(); + }, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); + } + + /** + * Master render method. + */ + protected render(): TemplateResult | void { + const _render = async (): Promise => { + await this._refreshPlayer(); + + if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) { + return dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_player')); + } + return html`${this._jsmpegCanvasElement}`; + }; + return html`${until( + _render(), + renderProgressIndicator({ + cardWideConfig: this.cardWideConfig, + }), + )}`; + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveJSMPEGStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-jsmpeg': FrigateCardLiveJSMPEG; + } +} diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts new file mode 100644 index 00000000..6cd0f4a6 --- /dev/null +++ b/src/components/live/live-webrtc-card.ts @@ -0,0 +1,199 @@ +import { Task } from '@lit-labs/task'; +import { HomeAssistant } from 'custom-card-helpers'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { localize } from '../../localize/localize.js'; +import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss'; +import { + CameraConfig, + CardWideConfig, + FrigateCardError, + FrigateCardMediaPlayer, +} from '../../types.js'; +import { dispatchMediaLoadedEvent } from '../../utils/media-info.js'; +import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js'; +import { renderTask } from '../../utils/task.js'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, +} from '../../utils/media.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; + +// Create a wrapper for AlexxIT's WebRTC card +// - https://github.com/AlexxIT/WebRTC +@customElement('frigate-card-live-webrtc-card') +export class FrigateCardLiveWebRTCCard + extends LitElement + implements FrigateCardMediaPlayer +{ + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + protected hass?: HomeAssistant; + + // A task to await the load of the WebRTC component. + protected _webrtcTask = new Task(this, this._getWebRTCCardElement, () => [1]); + + public async play(): Promise { + return this._getPlayer()?.play(); + } + + public async pause(): Promise { + this._getPlayer()?.pause(); + } + + public async mute(): Promise { + const player = this._getPlayer(); + if (player) { + player.muted = true; + } + } + + public async unmute(): Promise { + const player = this._getPlayer(); + if (player) { + player.muted = false; + } + } + + public isMuted(): boolean { + return this._getPlayer()?.muted ?? true; + } + + public async seek(seconds: number): Promise { + const player = this._getPlayer(); + if (player) { + player.currentTime = seconds; + } + } + + connectedCallback(): void { + super.connectedCallback(); + + // Reset the player when reconnected to the DOM. + // https://github.com/dermotduffy/frigate-hass-card/issues/996 + this.requestUpdate(); + } + + /** + * Get the underlying video player. + * @returns The player or `null` if not found. + */ + protected _getPlayer(): HTMLVideoElement | null { + const root = this.renderRoot?.querySelector('#webrtc') as + | (HTMLElement & { video?: HTMLVideoElement }) + | null; + return root?.video ?? null; + } + + protected async _getWebRTCCardElement(): Promise< + CustomElementConstructor | undefined + > { + await customElements.whenDefined('webrtc-camera'); + return customElements.get('webrtc-camera'); + } + + /** + * Create the WebRTC element. May throw. + */ + protected _createWebRTC(): HTMLElement | null { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const webrtcElement = this._webrtcTask.value; + if (webrtcElement && this.hass && this.cameraConfig) { + const webrtc = new webrtcElement() as HTMLElement & { + hass: HomeAssistant; + setConfig: (config: Record) => void; + }; + const config = { ...this.cameraConfig.webrtc_card }; + if (!config.url && !config.entity && this.cameraEndpoints?.webrtcCard) { + // This will never need to be signed, it is just used internally by the + // card as a stream name lookup. + config.url = this.cameraEndpoints.webrtcCard.endpoint; + } + webrtc.setConfig(config); + webrtc.hass = this.hass; + return webrtc; + } + return null; + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + const render = (): TemplateResult | void => { + let webrtcElement: HTMLElement | null; + try { + webrtcElement = this._createWebRTC(); + } catch (e) { + return dispatchErrorMessageEvent( + this, + e instanceof FrigateCardError + ? e.message + : localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message, + { context: (e as FrigateCardError).context }, + ); + } + if (webrtcElement) { + // Set the id to ensure that the relevant CSS styles will have + // sufficient specifity to overcome some styles that are otherwise + // applied to in Safari. + webrtcElement.id = 'webrtc'; + } + return html`${webrtcElement}`; + }; + + // Use a task to allow us to asynchronously wait for the WebRTC card to + // load, but yet still have the card load be followed by the updated() + // lifecycle callback (unlike just using `until`). + return renderTask(this, this._webrtcTask, render, { + inProgressFunc: () => + renderProgressIndicator({ + message: localize('error.webrtc_card_waiting'), + cardWideConfig: this.cardWideConfig, + }), + }); + } + + /** + * Updated lifecycle callback. + */ + public updated(): void { + // Extract the video component after it has been rendered and generate the + // media load event. + this.updateComplete.then(() => { + const video = this._getPlayer(); + if (video) { + const onloadeddata = video.onloadeddata; + + video.onloadeddata = (e) => { + if (onloadeddata) { + onloadeddata.call(video, e); + } + hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); + dispatchMediaLoadedEvent(this, video); + }; + } + }); + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveWebRTCCardStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-live-webrtc-card': FrigateCardLiveWebRTCCard; + } +} diff --git a/src/components/live/live.ts b/src/components/live/live.ts new file mode 100644 index 00000000..2f5ef65a --- /dev/null +++ b/src/components/live/live.ts @@ -0,0 +1,966 @@ +import { EmblaOptionsType } 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 { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { guard } from 'lit/directives/guard.js'; +import { keyed } from 'lit/directives/keyed.js'; +import { ConditionState, getOverriddenConfig } from '../../card-condition.js'; +import { localize } from '../../localize/localize.js'; +import liveStyle from '../../scss/live.scss'; +import liveCarouselStyle from '../../scss/live-carousel.scss'; +import liveProviderStyle from '../../scss/live-provider.scss'; +import { + CameraConfig, + CardWideConfig, + ExtendedHomeAssistant, + frigateCardConfigDefaults, + FrigateCardMediaPlayer, + LiveConfig, + LiveOverrides, + LiveProvider, + MediaLoadedInfo, + Message, + TransitionEffect, +} from '../../types.js'; +import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; +import { contentsChanged } from '../../utils/basic.js'; +import { + dispatchExistingMediaLoadedInfoAsEvent, + dispatchMediaUnloadedEvent, +} from '../../utils/media-info.js'; +import { dispatchViewContextChangeEvent, View } from '../../view/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 '../next-prev-control.js'; +import '../title-control.js'; +import '../surround.js'; +import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; +import { CameraManager } from '../../camera-manager/manager.js'; +import { HomeAssistant } from 'custom-card-helpers'; +import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; +import { HassEntity } from 'home-assistant-js-websocket'; +import { CameraEndpoints } from '../../camera-manager/types.js'; +import { playMediaMutingIfNecessary } from '../../utils/media.js'; + +interface LiveViewContext { + // A cameraID override (used for dependencies/substreams to force a different + // camera to be live rather than the camera selected in the view). + overrides?: Map; +} + +declare module 'view' { + interface ViewContext { + live?: LiveViewContext; + } +} + +interface LastMediaLoadedInfo { + mediaLoadedInfo: MediaLoadedInfo; + source: EventTarget; +} + +const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider'; + +/** + * Get the state object or dispatch an error. Used in `ha` and `image` live + * providers. + * @param element HTMLElement to dispatch errors from. + * @param hass Home Assistant object. + * @param cameraConfig Camera configuration. + * @returns + */ +export const getStateObjOrDispatchError = ( + element: HTMLElement, + hass: HomeAssistant, + cameraConfig?: CameraConfig, +): HassEntity | null => { + if (!cameraConfig?.camera_entity) { + dispatchErrorMessageEvent(element, localize('error.no_live_camera'), { + context: cameraConfig, + }); + return null; + } + + const stateObj = hass.states[cameraConfig.camera_entity]; + if (!stateObj) { + dispatchErrorMessageEvent(element, localize('error.live_camera_not_found'), { + context: cameraConfig, + }); + return null; + } + + if (stateObj.state === 'unavailable') { + dispatchMessageEvent(element, localize('error.live_camera_unavailable'), 'info', { + icon: 'mdi:connection', + context: cameraConfig, + }); + return null; + } + return stateObj; +}; + +@customElement('frigate-card-live') +export class FrigateCardLive extends LitElement { + @property({ attribute: false }) + public conditionState?: ConditionState; + + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public liveConfig?: LiveConfig; + + @property({ attribute: false, hasChanged: contentsChanged }) + public liveOverrides?: LiveOverrides; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + // Whether or not the live view is currently in the background (i.e. preloaded + // but not visible) + @state() + protected _inBackground?: boolean = false; + + // Intersection handler is used to detect when the live view flips between + // foreground and background (in preload mode). + protected _intersectionObserver: IntersectionObserver; + + // MediaLoadedInfo object and target from the underlying live object. In the + // case of pre-loading these may be propagated later (from the original + // source). + protected _lastMediaLoadedInfo: LastMediaLoadedInfo | null = null; + + protected _messageReceivedPostRender = false; + protected _renderKey = 0; + + constructor() { + super(); + this._intersectionObserver = new IntersectionObserver( + this._intersectionHandler.bind(this), + ); + } + + /** + * Called when the live view intersects with the viewport. + * @param entries The IntersectionObserverEntry entries (should be only 1). + */ + protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { + this._inBackground = !entries.some((entry) => entry.isIntersecting); + + if ( + !this._inBackground && + !this._messageReceivedPostRender && + this._lastMediaLoadedInfo + ) { + // 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( + // Specifically dispatch the event "where it came from", as otherwise + // the intermediate layers (e.g. media-carousel which controls the title + // popups) will not re-receive the events. + this._lastMediaLoadedInfo.source, + this._lastMediaLoadedInfo.mediaLoadedInfo, + ); + } + + // Trigger a re-render which may be necessary if the prior render resulted + // in a message. + if (this._messageReceivedPostRender && !this._inBackground) { + this.requestUpdate(); + } + } + + /** + * Determine whether the element should be updated. + * @param _changedProps The changed properties if any. + * @returns `true` if the element should be updated. + */ + // 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; + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + this._intersectionObserver.observe(this); + super.connectedCallback(); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + this._intersectionObserver.disconnect(); + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) { + return; + } + + // Notes: + // - See 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). + // - Various events are captured to prevent them propagating upwards if the + // card is in the background. + // - The entire returned template is keyed to allow for the whole template + // to be re-rendered in certain circumstances (specifically: if a message + // is received when the card is in the background). + const result = html`${keyed( + this._renderKey, + html` + ) => { + this._renderKey++; + this._messageReceivedPostRender = true; + if (this._inBackground) { + ev.stopPropagation(); + } + }} + @frigate-card:media:loaded=${(ev: CustomEvent) => { + this._lastMediaLoadedInfo = { + source: ev.composedPath()[0], + mediaLoadedInfo: ev.detail, + }; + if (this._inBackground) { + ev.stopPropagation(); + } + }} + @frigate-card:view:change=${(ev: CustomEvent) => { + if (this._inBackground) { + ev.stopPropagation(); + } + }} + > + + `, + )}`; + + this._messageReceivedPostRender = false; + return result; + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveStyle); + } +} + +@customElement('frigate-card-live-carousel') +export class FrigateCardLiveCarousel extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public liveConfig?: LiveConfig; + + @property({ attribute: false, hasChanged: contentsChanged }) + public liveOverrides?: LiveOverrides; + + @property({ attribute: false }) + public inBackground?: boolean; + + @property({ attribute: false }) + public conditionState?: ConditionState; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + // 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 { + super.updated(changedProperties); + + if (changedProperties.has('inBackground')) { + this.updateComplete.then(async () => { + const frigateCardMediaCarousel = this._refMediaCarousel.value; + if (frigateCardMediaCarousel) { + await frigateCardMediaCarousel.updateComplete; + // 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(); + } + } + }); + } + } + + /** + * Get the transition effect to use. + * @returns An TransitionEffect object. + */ + protected _getTransitionEffect(): TransitionEffect { + return ( + this.liveConfig?.transition_effect ?? + frigateCardConfigDefaults.live.transition_effect + ); + } + + protected _getSelectedCameraIndex(): number { + const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); + if (!cameraIDs || !this.view) { + return 0; + } + return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera)); + } + + /** + * Get the Embla options to use. + * @returns An EmblaOptionsType object or undefined for no options. + */ + protected _getOptions(): EmblaOptionsType { + return { + draggable: this.liveConfig?.draggable, + loop: true, + }; + } + + /** + * Get the Embla plugins to use. + * @returns A list of EmblaOptionsTypes. + */ + protected _getPlugins(): EmblaCarouselPlugins { + const cameras = this.cameraManager?.getStore().getVisibleCameraIDs(); + return [ + // Only enable wheel plugin if there is more than one camera. + ...(cameras && cameras.size > 1 + ? [ + WheelGesturesPlugin({ + // Whether the carousel is vertical or horizontal, interpret y-axis wheel + // gestures as scrolling for the carousel. + forceWheelAxis: 'y', + }), + ] + : []), + Lazyload({ + ...(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, + ...(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, + }), + }), + ]; + } + + /** + * 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 + * should lazy load, etc. `null` means lazy loading is disabled and everything + * should load simultaneously. + * @returns + */ + protected _getLazyLoadCount(): number | null { + // Defaults to fully-lazy loading. + return this.liveConfig?.lazy_load === false ? null : 0; + } + + /** + * Get slides to include in the render. + * @returns The slides to include in the render and an index keyed by camera + * name to slide number. + */ + protected _getSlides(): [TemplateResult[], Record] { + const visibleCameras = this.cameraManager?.getStore().getVisibleCameras(); + if (!visibleCameras) { + return [[], {}]; + } + + const slides: TemplateResult[] = []; + const cameraToSlide: Record = {}; + + for (const [cameraID, cameraConfig] of visibleCameras) { + const liveCameraID = + this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; + const liveCameraConfig = + cameraID === liveCameraID + ? cameraConfig + : this.cameraManager?.getStore().getCameraConfig(liveCameraID); + + const slide = liveCameraConfig + ? this._renderLive(liveCameraID, liveCameraConfig, slides.length) + : null; + if (slide) { + cameraToSlide[cameraID] = slides.length; + slides.push(slide); + } + } + return [slides, cameraToSlide]; + } + + /** + * Handle the user selecting a new slide in the carousel. + */ + protected _setViewHandler(ev: CustomEvent): void { + const cameras = this.cameraManager?.getStore().getVisibleCameras(); + if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) { + this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]); + } + } + + protected _setViewCameraID(cameraID?: string | null): void { + if (cameraID) { + this.view + ?.evolve({ + camera: cameraID, + // Reset the query and query results. + query: null, + queryResults: null, + }) + // Don't yet fetch thumbnails (they will be fetched when the carousel + // settles). + .mergeInContext({ thumbnails: { fetch: false } }) + .dispatchChangeEvent(this); + } + } + + /** + * Lazy load a slide. + * @param _index The slide number to lazy load. + * @param slide The slide to lazy load. + */ + protected _lazyloadOrUnloadSlide( + action: 'load' | 'unload', + _index: number, + slide: Element, + ): void { + if (slide instanceof HTMLSlotElement) { + slide = slide.assignedElements({ flatten: true })[0]; + } + + const liveProvider = slide?.querySelector( + FRIGATE_CARD_LIVE_PROVIDER, + ) as FrigateCardLiveProvider | null; + if (liveProvider) { + liveProvider.disabled = action !== 'load'; + } + } + + protected _renderLive( + cameraID: string, + cameraConfig: CameraConfig, + slideIndex: number, + ): TemplateResult | void { + if (!this.liveConfig || !this.hass || !this.cameraManager) { + return; + } + // 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 = { + ...this.conditionState, + camera: cameraID, + }; + + const config = getOverriddenConfig( + this.liveConfig, + this.liveOverrides, + conditionState, + ) as LiveConfig; + + const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID); + + return html` +
+ this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined, + )} + .label=${cameraMetadata?.title ?? ''} + .liveConfig=${config} + .hass=${this.hass} + .cardWideConfig=${this.cardWideConfig} + @frigate-card:media:loaded=${(ev: CustomEvent) => { + wrapMediaLoadedEventForCarousel(slideIndex, ev); + }} + @frigate-card:media:unloaded=${(ev: CustomEvent) => { + wrapMediaUnloadedEventForCarousel(slideIndex, ev); + }} + > + +
+ `; + } + + protected _getCameraIDsOfNeighbors(): [string | null, string | null] { + const cameras = this.cameraManager?.getStore().getVisibleCameras(); + if (!cameras || !this.view || !this.hass) { + return [null, null]; + } + const keys = Array.from(cameras.keys()); + const currentIndex = keys.indexOf(this.view.camera); + + if (currentIndex < 0 || cameras.size <= 1) { + return [null, null]; + } + + return [ + keys[currentIndex > 0 ? currentIndex - 1 : cameras.size - 1], + keys[currentIndex + 1 < cameras.size ? currentIndex + 1 : 0], + ]; + } + + /** + * Render the element. + * @returns A template to display to the user. + */ + protected render(): TemplateResult | void { + if (!this.liveConfig || !this.view || !this.hass || !this.cameraManager) { + return; + } + + const [slides, cameraToSlide] = this._getSlides(); + this._cameraToSlide = cameraToSlide; + if (!slides.length) { + return; + } + + const config = getOverriddenConfig( + this.liveConfig, + this.liveOverrides, + this.conditionState, + ) as LiveConfig; + + const [prevID, nextID] = this._getCameraIDsOfNeighbors(); + + const overrideCameraID = (cameraID: string): string => { + return this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; + }; + + const cameraMetadataPrevious = prevID + ? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(prevID)) + : null; + const cameraMetadataCurrent = this.cameraManager.getCameraMetadata( + this.hass, + overrideCameraID(this.view.camera), + ); + const cameraMetadataNext = nextID + ? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID)) + : null; + + // 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._setViewCameraID(prevID); + stopEventFromActivatingCardWideActions(ev); + }} + > + + ${slides} + { + this._setViewCameraID(nextID); + stopEventFromActivatingCardWideActions(ev); + }} + > + + + `; + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveCarouselStyle); + } +} + +@customElement(FRIGATE_CARD_LIVE_PROVIDER) +export class FrigateCardLiveProvider + extends LitElement + implements FrigateCardMediaPlayer +{ + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public cameraConfig?: CameraConfig; + + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + + @property({ attribute: false }) + 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). + @property({ attribute: true, type: Boolean }) + public disabled = false; + + // Label that is used for ARIA support and as tooltip. + @property({ attribute: false }) + public label = ''; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @state() + protected _isVideoMediaLoaded = false; + + protected _refProvider: Ref = createRef(); + + // A note on dynamic imports: + // + // We gather the dynamic live provider import promises and do not consider the + // update of the element complete until these imports have returned. Without + // this behavior calls to the media methods (e.g. `mute()`) may throw if the + // underlying code is not yet loaded. + // + // Test case: A card with a non-live view, but live pre-loaded, attempts to + // call mute() when the element first renders in the + // background. These calls fail without waiting for loading here. + protected _importPromises: Promise[] = []; + + public async play(): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; + await playMediaMutingIfNecessary(this, this._refProvider.value); + } + + public async pause(): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; + this._refProvider.value?.pause(); + } + + public async mute(): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; + this._refProvider.value?.mute(); + } + + public async unmute(): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; + this._refProvider.value?.unmute(); + } + + public isMuted(): boolean { + return this._refProvider.value?.isMuted() ?? true; + } + + public async seek(seconds: number): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; + this._refProvider.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 || + this.cameraConfig?.webrtc_card?.url + ) { + return 'webrtc-card'; + } else if (this.cameraConfig?.camera_entity) { + if (this.cardWideConfig?.performance?.profile === 'low') { + return 'image'; + } else { + return 'ha'; + } + } else if (this.cameraConfig?.frigate.camera_name) { + return 'jsmpeg'; + } + return frigateCardConfigDefaults.cameras.live_provider; + } + return this.cameraConfig?.live_provider || 'image'; + } + + /** + * 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); + if (this.liveConfig?.show_image_during_load) { + this._importPromises.push(import('./live-image.js')); + } + } + if (changedProps.has('cameraConfig')) { + const provider = this._getResolvedProvider(); + if (provider === 'jsmpeg') { + this._importPromises.push(import('./live-jsmpeg.js')); + } else if (provider === 'ha') { + this._importPromises.push(import('./live-ha.js')); + } else if (provider === 'webrtc-card') { + this._importPromises.push(import('./live-webrtc-card.js')); + } else if (provider === 'image') { + this._importPromises.push(import('./live-image.js')); + } else if (provider === 'go2rtc') { + this._importPromises.push(import('./live-go2rtc.js')); + } + } + } + + override async getUpdateComplete(): Promise { + // See 'A note on dynamic imports' above for explanation of why this is + // necessary. + const result = await super.getUpdateComplete(); + await Promise.all(this._importPromises); + this._importPromises = []; + return result; + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) { + return; + } + + // Set title and ariaLabel from the provided label property. + this.title = this.label; + this.ariaLabel = this.label; + + const provider = this._getResolvedProvider(); + const showImageDuringLoading = + !this._isVideoMediaLoaded && this._shouldShowImageDuringLoading(); + const providerClasses = { + hidden: showImageDuringLoading, + }; + + return html` + ${showImageDuringLoading || provider === 'image' + ? html` { + if (provider === 'image') { + // Only count the media has loaded if the required provider is + // the image (not just the temporary image shown during + // loading). + this._videoMediaShowHandler(); + } else { + ev.stopPropagation(); + } + }} + > + ` + : html``} + ${provider === 'ha' + ? html` + ` + : provider === 'go2rtc' + ? html` + ` + : provider === 'webrtc-card' + ? html` + ` + : provider === 'jsmpeg' + ? html` + ` + : html``} + `; + } + + /** + * Get styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(liveProviderStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 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 8f7c7280..f22806a1 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -12,7 +12,6 @@ import type { } from '../types.js'; import { dispatchFrigateCardEvent } from '../utils/basic'; import { - createMediaLoadedInfo, dispatchExistingMediaLoadedInfoAsEvent, isValidMediaLoadedInfo, } from '../utils/media-info.js'; @@ -22,17 +21,14 @@ import './next-prev-control.js'; import './carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { FrigateCardTitleControl } from './title-control.js'; +import debounce from 'lodash-es/debounce'; -const getEmptyImageSrc = (width: number, height: number) => - `data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`; -export const IMG_EMPTY = getEmptyImageSrc(16, 9); - -export interface CarouselMediaLoadedInfo { +interface CarouselMediaLoadedInfo { slide: number; mediaLoadedInfo: MediaLoadedInfo; } -export interface CarouselMediaUnloadedInfo { +interface CarouselMediaUnloadedInfo { slide: number; } @@ -84,21 +80,6 @@ export const wrapMediaLoadedEventForCarousel = ( }); }; -/** - * 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. @@ -125,12 +106,18 @@ export class FrigateCardMediaCarousel extends LitElement { @property({ attribute: false }) public carouselPlugins?: EmblaCarouselPlugins; + @property({ attribute: false, type: Number }) + public selected = 0; + @property({ attribute: true }) public transitionEffect?: TransitionEffect; @property({ attribute: false }) public label?: string; + @property({ attribute: false }) + public logo?: string; + @property({ attribute: false }) public titlePopupConfig?: TitleControlConfig; @@ -143,14 +130,17 @@ export class FrigateCardMediaCarousel extends LitElement { protected _boundAutoPlayHandler = this.autoPlay.bind(this); protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); - protected _boundAdaptContainerHeightToSlide = - this._adaptContainerHeightToSlide.bind(this); protected _boundTitleHandler = this._titleHandler.bind(this); + // Debounce multiple calls to adapt the container height. + protected _debouncedAdaptContainerHeightToSlide = debounce( + this._adaptContainerHeightToSlide.bind(this), + 1 * 100, + {trailing: true}); + // 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; @@ -161,7 +151,6 @@ export class FrigateCardMediaCarousel extends LitElement { // 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), ); @@ -272,10 +261,9 @@ export class FrigateCardMediaCarousel extends LitElement { this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); this.addEventListener( 'frigate-card:media:loaded', - this._boundAdaptContainerHeightToSlide, + this._debouncedAdaptContainerHeightToSlide, ); this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler); - this._resizeObserver.observe(this); this._intersectionObserver.observe(this); } @@ -287,10 +275,9 @@ export class FrigateCardMediaCarousel extends LitElement { this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler); this.removeEventListener( 'frigate-card:media:loaded', - this._boundAdaptContainerHeightToSlide, + this._debouncedAdaptContainerHeightToSlide, ); this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler); - this._resizeObserver.disconnect(); this._intersectionObserver.disconnect(); this._mediaLoadedInfo = {}; @@ -302,7 +289,7 @@ export class FrigateCardMediaCarousel extends LitElement { */ protected _reInitAndAdjustHeight(): void { this.frigateCardCarousel()?.carouselReInitWhenSafe(); - this._adaptContainerHeightToSlide(); + this._debouncedAdaptContainerHeightToSlide(); } /** @@ -331,36 +318,25 @@ export class FrigateCardMediaCarousel extends LitElement { * 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. + * This component does not use the stock Embla auto-height plugin as that + * resizes the container only on selection rather than media load. */ protected _adaptContainerHeightToSlide(): void { - const adaptCarouselHeight = (): void => { - 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`; - } + 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: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 - // right values here. requestAnimationFrame() works well for this. - window.requestAnimationFrame(adaptCarouselHeight); + } } /** * Fire a media show event when a slide is selected. */ - protected _dispatchMediaLoadedInfo(): void { - const slideIndex = this.frigateCardCarousel()?.getCarouselSelected()?.index; + protected _dispatchMediaLoadedInfo(selected: CarouselSelect): void { + const slideIndex = selected.index; if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) { dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]); } @@ -404,26 +380,36 @@ export class FrigateCardMediaCarousel extends LitElement { } protected render(): TemplateResult | void { + const selectSlide = (ev: CustomEvent): void => { + this._slideResizeObserver.disconnect(); + const parent = this.getRootNode(); + if (parent && parent instanceof ShadowRoot) { + this._slideResizeObserver.observe(parent.host); + } + + const selected = ev.detail; + this._slideResizeObserver.observe(selected.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', + selected, + ); + + // Dispatch media info. + this._dispatchMediaLoadedInfo(selected); + } + 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(); + selectSlide(ev); }} @frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)} @frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)} @@ -437,6 +423,7 @@ export class FrigateCardMediaCarousel extends LitElement { ${ref(this._titleControlRef)} .config=${this.titlePopupConfig} .text="${this.label}" + .logo="${this.logo}" .fitInto=${this as HTMLElement} > ` diff --git a/src/components/media-filter.ts b/src/components/media-filter.ts new file mode 100644 index 00000000..739ccd3c --- /dev/null +++ b/src/components/media-filter.ts @@ -0,0 +1,604 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + ReactiveController, + ReactiveControllerHost, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { DateRange } from '../camera-manager/range'; +import { localize } from '../localize/localize'; +import mediaFilterStyle from '../scss/media-filter.scss'; +import { executeMediaQueryForView } from '../utils/media-to-view.js'; +import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic'; +import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; +import './select'; +import { FrigateCardSelect, SelectOption, SelectValues } from './select'; +import uniqWith from 'lodash-es/uniqWith'; +import sub from 'date-fns/sub'; +import endOfDay from 'date-fns/endOfDay'; +import endOfYesterday from 'date-fns/endOfYesterday'; +import endOfToday from 'date-fns/esm/endOfToday'; +import startOfToday from 'date-fns/esm/startOfToday'; +import startOfDay from 'date-fns/startOfDay'; +import startOfYesterday from 'date-fns/startOfYesterday'; +import parse from 'date-fns/parse'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { View } from '../view/view'; +import { CameraManager } from '../camera-manager/manager'; +import { HomeAssistant } from 'custom-card-helpers'; +import { DataQuery, MediaMetadata, QueryType } from '../camera-manager/types'; +import format from 'date-fns/format'; +import endOfMonth from 'date-fns/endOfMonth'; +import isEqual from 'lodash-es/isEqual'; +import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries'; +import './select.js'; +import orderBy from 'lodash-es/orderBy'; +import { CardWideConfig } from '../types'; + +interface MediaFilterCoreDefaults { + cameraIDs?: string[]; + favorite?: MediaFilterCoreFavoriteSelection; + mediaType?: MediaFilterMediaType; + what?: string[]; + when?: string; + where?: string[]; + tags?: string[]; +} + +export enum MediaFilterCoreFavoriteSelection { + Favorite = 'favorite', + NotFavorite = 'not-favorite', +} + +export enum MediaFilterCoreWhen { + Today = 'today', + Yesterday = 'yesterday', + PastWeek = 'past-week', + PastMonth = 'past-month', +} + +export enum MediaFilterMediaType { + Clips = 'clips', + Snapshots = 'snapshots', + Recordings = 'recordings', +} + +@customElement('frigate-card-media-filter') +class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public view?: View; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + static elementDefinitions = { + 'frigate-card-select': FrigateCardSelect, + }; + + protected _mediaMetadataController?: MediaMetadataController; + + protected _mediaTypeOptions: SelectOption[]; + protected _cameraOptions?: SelectOption[]; + protected _whenOptions?: SelectOption[]; + protected _favoriteOptions: SelectOption[]; + + protected _defaults: MediaFilterCoreDefaults | null = null; + + protected _refMediaType: Ref = createRef(); + protected _refCamera: Ref = createRef(); + protected _refWhen: Ref = createRef(); + protected _refWhat: Ref = createRef(); + protected _refWhere: Ref = createRef(); + protected _refFavorite: Ref = createRef(); + protected _refTags: Ref = createRef(); + + constructor() { + super(); + this._favoriteOptions = [ + { + value: MediaFilterCoreFavoriteSelection.Favorite, + label: localize('media_filter.favorite'), + }, + { + value: MediaFilterCoreFavoriteSelection.NotFavorite, + label: localize('media_filter.not_favorite'), + }, + ]; + this._mediaTypeOptions = [ + { + value: MediaFilterMediaType.Clips, + label: localize('media_filter.media_types.clips'), + }, + { + value: MediaFilterMediaType.Snapshots, + label: localize('media_filter.media_types.snapshots'), + }, + { + value: MediaFilterMediaType.Recordings, + label: localize('media_filter.media_types.recordings'), + }, + ]; + } + + protected _stringToDateRange(input: string): DateRange { + const dates = input.split(','); + return { + start: parse(dates[0], 'yyyy-MM-dd', new Date()), + end: parse(dates[1], 'yyyy-MM-dd', new Date()), + }; + } + + protected _dateRangeToString(when: DateRange): string { + return `${formatDate(when.start)},${formatDate(when.end)}`; + } + + protected _getWhen(): DateRange | null { + const value = this._refWhen.value?.value; + if (!value || Array.isArray(value)) { + return null; + } + const now = new Date(); + switch (value) { + case MediaFilterCoreWhen.Today: + return { start: startOfToday(), end: endOfToday() }; + case MediaFilterCoreWhen.Yesterday: + return { start: startOfYesterday(), end: endOfYesterday() }; + case MediaFilterCoreWhen.PastWeek: + return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) }; + case MediaFilterCoreWhen.PastMonth: + return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) }; + default: + return this._stringToDateRange(value); + } + } + + protected async _valueChangedHandler( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ev: CustomEvent<{ value: unknown }>, + ): Promise { + const cameras = this.cameraManager?.getStore().getVisibleCameras(); + if (!this.hass || !cameras || !this.cameraManager || !this.view) { + return; + } + + const getArrayValueAsSet = (val?: SelectValues): Set | null => { + // The reported value may be '' if the field is clearable (i.e. the user + // can click 'x'). + if (val && Array.isArray(val) && val.length && !val.includes('')) { + return new Set([...val]); + } + return null; + }; + + const cameraIDs = + getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(cameras.keys()); + const mediaType = this._refMediaType.value?.value as + | MediaFilterMediaType + | undefined; + const when = this._getWhen(); + const favorite = this._refFavorite.value?.value + ? this._refFavorite.value.value === MediaFilterCoreFavoriteSelection.Favorite + : null; + + // A note on views: + // - In the below, if the user selects a camera to view media for, the main + // view camera is also set to that value (e.g. a user browsing the + // gallery, chooses a different camera in the media filter, then + // subsequently chooses the live button -- they would expect the live view + // for that filtered camera not the prior camera). + // - Similarly, if the user chooses clips or snapshots, set the actual view + // to 'clips' or 'snapshots' in order to ensure the right icon is shown as + // selected in the menu. + const limit = this.cardWideConfig?.performance?.features.media_chunk_size; + + if ( + mediaType === MediaFilterMediaType.Clips || + mediaType === MediaFilterMediaType.Snapshots + ) { + const where = getArrayValueAsSet(this._refWhere.value?.value); + const what = getArrayValueAsSet(this._refWhat.value?.value); + const tags = getArrayValueAsSet(this._refTags.value?.value); + + const queries = new EventMediaQueries([ + { + type: QueryType.Event, + cameraIDs: cameraIDs, + ...(tags && { tags: tags }), + ...(what && { what: what }), + ...(where && { where: where }), + ...(favorite !== null && { favorite: favorite }), + ...(when && { start: when.start, end: when.end }), + ...(limit && { limit: limit }), + ...(mediaType === MediaFilterMediaType.Clips && { hasClip: true }), + ...(mediaType === MediaFilterMediaType.Snapshots && { + hasSnapshot: true, + }), + }, + ]); + + ( + await executeMediaQueryForView( + this, + this.hass, + this.cameraManager, + this.view, + queries, + { + // See 'A note on views' above for these two arguments. + ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), + targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots', + }, + ) + )?.dispatchChangeEvent(this); + } else if (mediaType === MediaFilterMediaType.Recordings) { + const queries = new RecordingMediaQueries([ + { + type: QueryType.Recording, + cameraIDs: cameraIDs, + ...(limit && { limit: limit }), + ...(when && { start: when.start, end: when.end }), + }, + ]); + + ( + await executeMediaQueryForView( + this, + this.hass, + this.cameraManager, + this.view, + queries, + { + // See 'A note on views' above for these two arguments. + ...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }), + targetView: 'recordings', + }, + ) + )?.dispatchChangeEvent(this); + } + } + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('cameraManager')) { + const cameras = this.cameraManager?.getStore().getVisibleCameras(); + if (cameras) { + this._cameraOptions = Array.from(cameras.keys()).map((cameraID) => ({ + value: cameraID, + label: this.hass + ? this.cameraManager?.getCameraMetadata(this.hass, cameraID)?.title ?? '' + : '', + })); + } + } + + if (changedProps.has('cameraManager') && this.hass && this.cameraManager) { + this._mediaMetadataController = new MediaMetadataController( + this, + this.hass, + this.cameraManager, + ); + } + + // Relative time based options are not pre-computed here to ensure relative + // dates (e.g. 'today') are always calculated when activated not when + // rendered. + this._whenOptions = [ + { + value: MediaFilterCoreWhen.Today, + label: localize('media_filter.whens.today'), + }, + { + value: MediaFilterCoreWhen.Yesterday, + label: localize('media_filter.whens.yesterday'), + }, + { + value: MediaFilterCoreWhen.PastWeek, + label: localize('media_filter.whens.past_week'), + }, + { + value: MediaFilterCoreWhen.PastMonth, + label: localize('media_filter.whens.past_month'), + }, + ...(this._mediaMetadataController?.whenOptions ?? []), + ]; + + if (changedProps.has('view')) { + const newDefaults = this._getDefaultsFromView(); + if (!isEqual(newDefaults, this._defaults)) { + this._defaults = newDefaults; + } + } + } + + protected _getDefaultsFromView(): MediaFilterCoreDefaults | null { + const queries = this.view?.query?.getQueries(); + const cameras = this.cameraManager?.getStore().getVisibleCameras(); + if (!this.view || !queries || !cameras) { + return null; + } + + let mediaType: MediaFilterMediaType | undefined; + let cameraIDs: string[] | undefined; + let what: string[] | undefined; + let where: string[] | undefined; + let favorite: MediaFilterCoreFavoriteSelection | undefined; + let tags: string[] | undefined; + + const cameraIDSets = uniqWith( + queries.map((query: DataQuery) => query.cameraIDs), + isEqual, + ); + // Special note: If all visible cameras are selected, this is the same as no + // selector at all. + if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, cameras)) { + cameraIDs = [...queries[0].cameraIDs]; + } + + const favoriteValues = uniqWith( + queries.map((query) => query.favorite), + isEqual, + ); + if (favoriteValues.length === 1 && queries[0].favorite !== undefined) { + favorite = queries[0].favorite + ? MediaFilterCoreFavoriteSelection.Favorite + : MediaFilterCoreFavoriteSelection.NotFavorite; + } + + if (MediaQueriesClassifier.areEventQueries(this.view.query)) { + const queries = this.view.query.getQueries(); + if (!queries) { + return null; + } + + const hasClips = uniqWith( + queries.map((query) => query.hasClip), + isEqual, + ); + const hasSnapshots = uniqWith( + queries.map((query) => query.hasSnapshot), + isEqual, + ); + if (hasClips.length === 1 && hasSnapshots.length === 1) { + mediaType = !!hasClips[0] + ? MediaFilterMediaType.Clips + : !!hasSnapshots[0] + ? MediaFilterMediaType.Snapshots + : undefined; + } + + const whatSets = uniqWith( + queries.map((query) => query.what), + isEqual, + ); + if (whatSets.length === 1 && queries[0].what?.size) { + what = [...queries[0].what]; + } + const whereSets = uniqWith( + queries.map((query) => query.where), + isEqual, + ); + if (whereSets.length === 1 && queries[0].where?.size) { + where = [...queries[0].where]; + } + const tagsSets = uniqWith( + queries.map((query) => query.tags), + isEqual, + ); + if (tagsSets.length === 1 && queries[0].tags?.size) { + tags = [...queries[0].tags]; + } + } else if (MediaQueriesClassifier.areRecordingQueries(this.view.query)) { + mediaType = MediaFilterMediaType.Recordings; + } + + return { + ...(mediaType && { mediaType: mediaType }), + ...(cameraIDs && { cameraIDs: cameraIDs }), + ...(what && { what: what }), + ...(where && { where: where }), + ...(favorite !== undefined && { favorite: favorite }), + ...(tags && { tags: tags }) + }; + } + + protected render(): TemplateResult | void { + if (!this._mediaMetadataController) { + return; + } + + const areEvents = !!( + this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query) + ); + const areRecordings = !!( + this.view?.query && MediaQueriesClassifier.areRecordingQueries(this.view.query) + ); + const managerCapabilities = this.cameraManager?.getAggregateCameraCapabilities(); + + // Which media controls are shown depends on the view. + const showFavoriteControl = areEvents + ? !!managerCapabilities?.canFavoriteEvents + : areRecordings + ? !!managerCapabilities?.canFavoriteRecordings + : false; + + return html` + + + + + + ${areEvents && this._mediaMetadataController.whatOptions.length + ? html` + ` + : ''} + ${areEvents && this._mediaMetadataController.tagsOptions.length + ? html` + ` + : ''} + ${areEvents && this._mediaMetadataController.whereOptions.length + ? html` + ` + : ''} + ${showFavoriteControl + ? html` + + + ` + : ''}`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(mediaFilterStyle); + } +} + +export class MediaMetadataController implements ReactiveController { + protected _host: ReactiveControllerHost; + protected _hass: HomeAssistant; + protected _cameraManager: CameraManager; + + public tagsOptions: SelectOption[] = []; + public whenOptions: SelectOption[] = []; + public whatOptions: SelectOption[] = []; + public whereOptions: SelectOption[] = []; + + constructor( + host: ReactiveControllerHost, + hass: HomeAssistant, + cameraManager: CameraManager, + ) { + this._host = host; + this._hass = hass; + this._cameraManager = cameraManager; + host.addController(this); + } + + protected _dateRangeToString(when: DateRange): string { + return `${formatDate(when.start)},${formatDate(when.end)}`; + } + + async hostConnected() { + let metadata: MediaMetadata | null; + try { + metadata = await this._cameraManager.getMediaMetadata(this._hass); + } catch (e) { + errorToConsole(e as Error); + return; + } + if (!metadata) { + return; + } + + if (metadata.what) { + this.whatOptions = [...metadata.what] + .sort() + .map((what) => ({ value: what, label: prettifyTitle(what) })); + } + if (metadata.where) { + this.whereOptions = [...metadata.where] + .sort() + .map((where) => ({ value: where, label: prettifyTitle(where) })); + } + if (metadata.tags) { + this.tagsOptions = [...metadata.tags] + .sort() + .map((tag) => ({ value: tag, label: prettifyTitle(tag) })); + } + if (metadata.days) { + const yearMonths: Set = new Set(); + [...metadata.days].forEach((day) => { + // An efficient conversion: "2023-01-26" -> "2023-01" + yearMonths.add(day.substring(0, 7)); + }); + const monthStarts: Date[] = []; + yearMonths.forEach((yearMonth) => { + monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date())); + }); + this.whenOptions = orderBy(monthStarts, (date) => date.getTime(), 'desc').map( + (monthStart) => ({ + label: format(monthStart, 'MMMM yyyy'), + value: this._dateRangeToString({ + start: monthStart, + end: endOfMonth(monthStart), + }), + }), + ); + } + this._host.requestUpdate(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-media-filter': FrigateCardMediaFilter; + } +} diff --git a/src/components/menu.ts b/src/components/menu.ts index 6201f4a7..1ad8eae2 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -5,7 +5,7 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS + unsafeCSS, } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -19,17 +19,18 @@ import type { MenuButton, MenuConfig, MenuItem, - StateParameters + StateParameters, } from '../types.js'; import { convertActionToFrigateCardCustomAction, frigateCardHandleActionConfig, frigateCardHasAction, - getActionConfigGivenAction + getActionConfigGivenAction, } from '../utils/action.js'; -import { FRIGATE_ICON_SVG_PATH } from '../utils/frigate.js'; +import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js'; import { refreshDynamicStateParameters } from '../utils/ha'; import './submenu.js'; +import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; @@ -64,6 +65,9 @@ export class FrigateCardMenu extends LitElement { @property({ attribute: false }) public buttons: MenuButton[] = []; + @property({ attribute: false }) + public entityRegistryManager?: EntityRegistryManager; + /** * Determine if a given menu configuration is a hiding menu. * @param menuConfig The menu configuration. @@ -226,9 +230,6 @@ export class FrigateCardMenu extends LitElement { * @returns A rendered template or void. */ protected _renderButton(button: MenuButton): TemplateResult | void { - if (button.enabled === false) { - return; - } if (button.type === 'custom:frigate-card-menu-submenu') { return html` `; } - let stateParameters: StateParameters = { ...button }; + let stateParameters = { ...button } as StateParameters; const svgPath = stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : ''; @@ -306,16 +308,19 @@ export class FrigateCardMenu extends LitElement { } // If the hidden menu isn't expanded, only show the Frigate button. - const matchingButtons = + 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); + : this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON) + ).filter((button) => button.enabled !== false); const opposingButtons = style !== 'hidden' || this.expanded - ? this.buttons.filter((button) => button.alignment === 'opposing') + ? this.buttons.filter( + (button) => button.alignment === 'opposing' && button.enabled !== false, + ) : []; const matchingStyle = { @@ -342,7 +347,7 @@ export class FrigateCardMenu extends LitElement { } declare global { - interface HTMLElementTagNameMap { - "frigate-card-menu": FrigateCardMenu - } + interface HTMLElementTagNameMap { + 'frigate-card-menu': FrigateCardMenu; + } } diff --git a/src/components/message.ts b/src/components/message.ts index 33c7469c..e233379e 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -1,10 +1,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; +import { ClassInfo, classMap } from 'lit/directives/class-map.js'; +import { ref, Ref } from 'lit/directives/ref.js'; import { TROUBLESHOOTING_URL } from '../const.js'; import { localize } from '../localize/localize.js'; import messageStyle from '../scss/message.scss'; -import { FrigateCardError, Message, MessageType } from '../types.js'; +import { CardWideConfig, FrigateCardError, Message, MessageType } from '../types.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; @customElement('frigate-card-message') @@ -28,7 +29,7 @@ export class FrigateCardMessage extends LitElement { dotdotdot: !!this.dotdotdot, }; return html`
-
+
@@ -77,16 +78,25 @@ export class FrigateCardErrorMessage extends LitElement { } } +type FrigateCardProgressIndicatorSize = 'tiny' | 'small' | 'medium' | 'large'; + @customElement('frigate-card-progress-indicator') export class FrigateCardProgressIndicator extends LitElement { @property({ attribute: false }) public message: string | TemplateResult = ''; + @property({ attribute: false }) + public animated = false; + + @property({ attribute: false }) + public size: FrigateCardProgressIndicatorSize = 'large'; + protected render(): TemplateResult { return html`
- - - + ${this.animated + ? html` + ` + : html``} ${this.message ? html`${this.message}` : html``}
`; } @@ -112,9 +122,22 @@ export function renderMessage(message: Message): TemplateResult { return html``; } -export function renderProgressIndicator(message?: string): TemplateResult { +export function renderProgressIndicator(options?: { + message?: string; + cardWideConfig?: CardWideConfig; + componentRef?: Ref; + classes?: ClassInfo; + size?: FrigateCardProgressIndicatorSize; +}): TemplateResult { return html` - + `; } @@ -167,9 +190,13 @@ export function dispatchErrorMessageEvent( */ export function dispatchFrigateCardErrorEvent( element: EventTarget, - error: FrigateCardError, + error: unknown, ): void { - dispatchErrorMessageEvent(element, error.message, { context: error.context }); + if (error instanceof Error) { + dispatchErrorMessageEvent(element, error.message, { + ...(error instanceof FrigateCardError && { context: error.context }), + }); + } } declare global { diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 5a7ccd67..a4943e71 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -52,9 +52,9 @@ export class FrigateCardNextPreviousControl extends LitElement { const classes = { controls: true, - previous: this.direction == 'previous', - next: this.direction == 'next', - thumbnails: this._controlConfig.style == 'thumbnails', + previous: this.direction === 'previous', + next: this.direction === 'next', + thumbnails: this._controlConfig.style === 'thumbnails', icons: ['chevrons', 'icons'].includes(this._controlConfig.style), button: ['chevrons', 'icons'].includes(this._controlConfig.style), }; @@ -62,7 +62,7 @@ export class FrigateCardNextPreviousControl extends LitElement { if (['chevrons', 'icons'].includes(this._controlConfig.style)) { let icon: string; if (this._controlConfig.style === 'chevrons') { - icon = this.direction == 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right'; + icon = this.direction === 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right'; } else { if (!this.icon) { return html``; @@ -91,7 +91,7 @@ export class FrigateCardNextPreviousControl extends LitElement { aria-label="${this.label}" />` : html``, - () => html`
`, + { inProgressFunc: () => html`
` }, ); } diff --git a/src/components/select.ts b/src/components/select.ts new file mode 100644 index 00000000..a56930a9 --- /dev/null +++ b/src/components/select.ts @@ -0,0 +1,89 @@ +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import selectStyle from '../scss/select.scss'; +import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic'; +import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; +import { grSelectElements } from '../scoped-elements/gr-select'; +import isEqual from 'lodash-es/isEqual'; +import '../scoped-elements/gr-select'; + +export interface SelectOption { + label: string; + value: string; +} + +export type SelectValues = string | string[]; + +type SelectElement = HTMLElement & { + value: SelectValues; +}; + +export class FrigateCardSelect extends ScopedRegistryHost(LitElement) { + @property({ attribute: false, hasChanged: contentsChanged }) + public options?: SelectOption[]; + + @property({ attribute: false, hasChanged: contentsChanged }) + public value?: SelectValues; + + @property({ attribute: true }) + public label?: string; + + @property({ attribute: true }) + public placeholder?: string; + + @property({ attribute: true, type: Boolean }) + public multiple?: boolean = false; + + @property({ attribute: true, type: Boolean }) + public clearable?: boolean = false; + + protected _previouslyReportedValue?: SelectValues; + protected _refSelect: Ref = createRef(); + + static elementDefinitions = { + ...grSelectElements, + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _valueChangedHandler(_ev: CustomEvent<{ value: unknown }>): void { + const value: SelectValues | undefined = this._refSelect.value?.value; + // The underlying gr-select element is very sensitive and occasionally fires + // the change event even if the value has not actually changed. Prevent that + // from propagating upwards. + if (value !== undefined && !isEqual(this.value, value)) { + this.value = value; + dispatchFrigateCardEvent(this, 'select:change', value); + } + } + + protected render(): TemplateResult | void { + return html` + ${this.options?.map( + (option) => + html`${option.label}`, + )} + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(selectStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-select': FrigateCardSelect; + } +} diff --git a/src/components/submenu.ts b/src/components/submenu.ts index a1bd6247..1f3abe45 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -7,9 +7,9 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { styleMap } from 'lit/directives/style-map.js'; +import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../action-handler-directive.js'; import submenuStyle from '../scss/submenu.scss'; import { @@ -23,6 +23,8 @@ import { stopEventFromActivatingCardWideActions, } from '../utils/action.js'; import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha'; +import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; +import { getEntityStateTranslation } from '../utils/ha/entity-state-translation.js'; import { domainIcon } from '../utils/icons/domain-icon.js'; @customElement('frigate-card-submenu') @@ -37,7 +39,7 @@ export class FrigateCardSubmenu extends LitElement { if (!this.hass) { return; } - const stateParameters = refreshDynamicStateParameters(this.hass, { ...item }); + const stateParameters = refreshDynamicStateParameters(this.hass, { ...item } as StateParameters); const getIcon = (stateParameters: StateParameters): TemplateResult => { if (stateParameters.icon) { return html` stopEventFromActivatingCardWideActions(ev)} > ; + protected _generatedSubmenu?: MenuSubmenu; /** @@ -138,12 +146,39 @@ export class FrigateCardSubmenuSelect extends LitElement { // No need to update the submenu unless the select entity has changed. const oldHass = changedProps.get('hass') as HomeAssistant | undefined; return ( - changedProps.size != 1 || + !changedProps.has('hass') || + !oldHass || !this.submenuSelect || - (!!oldHass && isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity])) + isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity]) ); } + protected async _refreshOptionTitles(): Promise { + if (!this.hass || !this.submenuSelect) { + return; + } + const entityID = this.submenuSelect.entity; + const stateObj = this.hass.states[entityID]; + const options = stateObj?.attributes?.options; + const entity = + (await this.entityRegistryManager?.getEntity(this.hass, entityID)) ?? null; + + const optionTitles = {}; + for (const option of options) { + const title = getEntityStateTranslation(this.hass, entityID, { + ...(entity && { entity: entity }), + state: option, + }); + if (title) { + optionTitles[option] = title; + } + } + + // This will cause a re-render with the updated title if it is + // different. + this._optionTitles = optionTitles; + } + /** * Called when the render function will be called. */ @@ -151,8 +186,13 @@ export class FrigateCardSubmenuSelect extends LitElement { if (!this.submenuSelect || !this.hass) { return; } - const entity = this.submenuSelect.entity; - const stateObj = this.hass.states[entity]; + + if (!this._optionTitles) { + this._refreshOptionTitles(); + } + + const entityID = this.submenuSelect.entity; + const stateObj = this.hass.states[entityID]; const options = stateObj?.attributes?.options; if (!stateObj || !options) { return; @@ -165,7 +205,7 @@ export class FrigateCardSubmenuSelect extends LitElement { icon: domainIcon('select'), // Pull out the dynamic properties (like icon, and title) from the state. - ...refreshDynamicStateParameters(this.hass, this.submenuSelect), + ...refreshDynamicStateParameters(this.hass, this.submenuSelect as StateParameters), // Override it with anything explicitly set in the submenuSelect. ...this.submenuSelect, @@ -180,26 +220,20 @@ export class FrigateCardSubmenuSelect extends LitElement { delete submenu['options']; for (const option of options) { - // If there's a device_class there may be a localized translation of the - // select title available via HASS. - const title = stateObj.attributes.device_class - ? this.hass.localize( - `component.select.state.${stateObj.attributes.device_class}.${option}`, - ) - : option; + const title = this._optionTitles?.[option] ?? option; submenu.items.push({ state_color: true, selected: stateObj.state === option, enabled: true, title: title || option, - ...((entity.startsWith('select.') || entity.startsWith('input_select.')) && { + ...((entityID.startsWith('select.') || entityID.startsWith('input_select.')) && { tap_action: { action: 'call-service', - service: entity.startsWith('select.') + service: entityID.startsWith('select.') ? 'select.select_option' : 'input_select.select_option', service_data: { - entity_id: entity, + entity_id: entityID, option: option, }, }, diff --git a/src/components/surround-basic.ts b/src/components/surround-basic.ts new file mode 100644 index 00000000..a49c5f1d --- /dev/null +++ b/src/components/surround-basic.ts @@ -0,0 +1,90 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { customElement, property } from 'lit/decorators.js'; +import { DrawerIcons, FrigateCardDrawer } from './drawer.js'; + +import './drawer.js'; + +import surroundBasicStyle from '../scss/surround-basic.scss'; + +interface FrigateCardDrawerOpen { + drawer: 'left' | 'right'; +} + +@customElement('frigate-card-surround-basic') +export class FrigateCardSurroundBasic extends LitElement { + @property({ attribute: false }) + public drawerIcons?: { + left?: DrawerIcons; + right?: DrawerIcons; + }; + + 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(surroundBasicStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-surround-basic': FrigateCardSurroundBasic; + } +} diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts deleted file mode 100644 index 2b6978a8..00000000 --- a/src/components/surround-thumbnails.ts +++ /dev/null @@ -1,194 +0,0 @@ -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 index 414058ab..79f69242 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -1,49 +1,154 @@ -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 { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; import surroundStyle from '../scss/surround.scss'; +import { + CardWideConfig, + ClipsOrSnapshotsOrAll, + ExtendedHomeAssistant, + MiniTimelineControlConfig, + ThumbnailsControlConfig, +} from '../types.js'; +import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; +import { CameraManager } from '../camera-manager/manager.js'; +import { View } from '../view/view.js'; +import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; +import './surround-basic.js'; +import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view'; +import { getAllDependentCameras } from '../utils/camera.js'; +import type { DataQuery } from '../camera-manager/types'; -interface FrigateCardDrawerOpen { - drawer: 'left' | 'right'; +interface ThumbnailViewContext { + // Whether or not to fetch thumbnails. + fetch?: boolean; +} + +declare module 'view' { + interface ViewContext { + thumbnails?: ThumbnailViewContext; + } } @customElement('frigate-card-surround') export class FrigateCardSurround extends LitElement { - protected _refDrawerLeft: Ref = createRef(); - protected _refDrawerRight: Ref = createRef(); - protected _boundDrawerHandler = this._drawerHandler.bind(this); + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false, hasChanged: contentsChanged }) + public thumbnailConfig?: ThumbnailsControlConfig; + + @property({ attribute: false, hasChanged: contentsChanged }) + public timelineConfig?: MiniTimelineControlConfig; + + // If fetchMedia is not specified, no fetching is done. + @property({ attribute: false, hasChanged: contentsChanged }) + public fetchMedia?: ClipsOrSnapshotsOrAll; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + protected _cameraIDsForTimeline?: Set; /** - * Component connected callback. + * Fetch thumbnail media when a target is not specified in the view (e.g. for + * the live view). + * @param param Task parameters. + * @returns */ - 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; + protected async _fetchMedia(): Promise { + if ( + !this.cameraManager || + !this.cardWideConfig || + !this.fetchMedia || + !this.hass || + !this.view || + this.view.query || + !this.thumbnailConfig || + this.thumbnailConfig.mode === 'none' || + !(this.view.context?.thumbnails?.fetch ?? true) + ) { + return; } + await changeViewToRecentEventsForCameraAndDependents( + this, + this.hass, + this.cameraManager, + this.cardWideConfig, + this.view, + { + targetView: this.view.view, + mediaType: this.fetchMedia, + select: 'latest', + }, + ); + } + + /** + * Determine if a drawer is being used. + * @returns `true` if a drawer is used, `false` otherwise. + */ + protected _hasDrawer(): boolean { + return ( + !!this.thumbnailConfig && ['left', 'right'].includes(this.thumbnailConfig.mode) + ); + } + + /** + * Called before each update. + */ + protected willUpdate(changedProperties: PropertyValues): void { + if (this.timelineConfig?.mode && this.timelineConfig.mode !== 'none') { + import('./timeline.js'); + } + + // Only reset the timeline cameraIDs when the media materially changes (and + // not on every view change, since the view will change frequently when the + // user is scrubbing video). + if ( + changedProperties.has('view') && + View.isMajorMediaChange(changedProperties.get('view'), this.view) + ) { + this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; + } + + // 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', 'fetch', 'browseMediaParams'].some((prop) => changedProperties.has(prop)) + ) { + this._fetchMedia(); + } + } + + protected _getCameraIDsForTimeline(): Set | null { + if (!this.view) { + return null; + } + if (this.view?.is('live')) { + return getAllDependentCameras(this.cameraManager, this.view.camera); + } + if (this.view.isViewerView()) { + return new Set( + this.view.query + ?.getQueries() + ?.map((query: DataQuery) => [...query.cameraIDs]) + .flat(), + ); + } + return null; } /** @@ -51,15 +156,78 @@ export class FrigateCardSurround extends LitElement { * @returns A rendered template. */ protected render(): TemplateResult | void { - return html` + if (!this.hass || !this.view) { + 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.thumbnailConfig && this._hasDrawer()) { + dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, { + drawer: this.thumbnailConfig.mode, + }); + } + }; + + return html` changeDrawer(ev, 'open')} + @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} + > + ${this.thumbnailConfig && this.thumbnailConfig.mode !== 'none' + ? html` changeDrawer(ev, 'close')} + @frigate-card:thumbnail-carousel:tap=${( + ev: CustomEvent, + ) => { + const media = ev.detail.queryResults.getSelectedResult(); + if (media) { + this.view + ?.evolve({ + view: 'media', + queryResults: ev.detail.queryResults, + ...(media.getCameraID() && { camera: media.getCameraID() }), + }) + .removeContext('timeline') + // 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). + .dispatchChangeEvent(ev.composedPath()[0]); + } + }} + > + ` + : ''} + ${this.timelineConfig && this.timelineConfig.mode !== 'none' + ? html` + ` + : ''} - - - - - - - `; + `; } /** @@ -71,7 +239,7 @@ export class FrigateCardSurround extends LitElement { } declare global { - interface HTMLElementTagNameMap { - "frigate-card-surround": FrigateCardSurround - } + interface HTMLElementTagNameMap { + 'frigate-card-surround': FrigateCardSurround; + } } diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 9136a54d..cc550fe7 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -8,29 +8,23 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +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 thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; -import { - CameraConfig, - ExtendedHomeAssistant, - FrigateBrowseMediaSource, - ThumbnailsControlConfig, -} from '../types.js'; +import { ExtendedHomeAssistant, 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 { dispatchFrigateCardEvent } from '../utils/basic.js'; +import { View } from '../view/view.js'; +import { MediaQueriesResults } from '../view/media-queries-results'; import { FrigateCardCarousel } from './carousel.js'; import './thumbnail.js'; import './carousel.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { CameraManager } from '../camera-manager/manager.js'; export interface ThumbnailCarouselTap { - slideIndex: number; - target: FrigateBrowseMediaSource; - childIndex: number; + queryResults: MediaQueriesResults; } @customElement('frigate-card-thumbnail-carousel') @@ -41,13 +35,8 @@ export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) 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; + public cameraManager?: CameraManager; protected _refCarousel: Ref = createRef(); @@ -59,10 +48,14 @@ export class FrigateCardThumbnailCarousel extends LitElement { @property({ attribute: false }) public config?: ThumbnailsControlConfig; - @state() - protected _selected: number | null = null; + @property({ attribute: false }) + public selected? = 0; + + protected _carouselOptions?: EmblaOptionsType = { + containScroll: 'keepSnaps', + dragFree: true, + }; - protected _carouselOptions?: EmblaOptionsType; protected _carouselPlugins: EmblaPluginType[] = [ WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel @@ -76,15 +69,6 @@ export class FrigateCardThumbnailCarousel extends LitElement { this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this)); } - @property({ attribute: false }) - set selected(selected: number | null) { - this._selected = selected; - this.style.setProperty( - '--frigate-card-carousel-thumbnail-opacity', - selected === null ? '1.0' : '0.4', - ); - } - /** * Handle gallery resize. */ @@ -108,31 +92,20 @@ export class FrigateCardThumbnailCarousel extends LitElement { super.disconnectedCallback(); } - /** - * Get the Embla options to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getOptions(): EmblaOptionsType { - return { - containScroll: 'keepSnaps', - dragFree: true, - startIndex: this._selected ?? 0, - }; - } /** * Get slides to include in the render. * @returns The slides to include in the render. */ protected _getSlides(): TemplateResult[] { - if (!this.target || !this.target.children || !this.target.children.length) { + if (!this.view?.query || !this.view.queryResults?.hasResults()) { return []; } const slides: TemplateResult[] = []; - for (let i = 0; i < this.target.children.length; ++i) { - const thumbnail = this._renderThumbnail(this.target, i, slides.length); + for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) { + const thumbnail = this._renderThumbnail(i); if (thumbnail) { - slides.push(thumbnail); + slides[i] = thumbnail; } } return slides; @@ -155,28 +128,11 @@ export class FrigateCardThumbnailCarousel extends LitElement { } } - 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); - } - }); + if (changedProps.has('selected')) { + this.style.setProperty( + '--frigate-card-carousel-thumbnail-opacity', + this.selected === undefined ? '1.0' : '0.4', + ); } } @@ -185,44 +141,36 @@ export class FrigateCardThumbnailCarousel extends LitElement { * @param mediaToRender The media item to render. * @returns A template or void if the item could not be rendered. */ - protected _renderThumbnail( - parent: FrigateBrowseMediaSource, - childIndex: number, - slideIndex: number, - ): TemplateResult | void { - if ( - !parent.children || - !parent.children.length || - !isTrueMedia(parent.children[childIndex]) - ) { + protected _renderThumbnail(index: number): TemplateResult | void { + const media = this.view?.queryResults?.getResult(index) ?? null; + if (!media || !this.view) { return; } const classes = { embla__slide: true, - 'slide-selected': this._selected === childIndex, + 'slide-selected': this.selected === index, }; - const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null; + const seekTarget = this.view?.context?.mediaViewer?.seek; return html` { - if (this._refCarousel.value?.carouselClickAllowed()) { + ?show_download_control=${this.config?.show_download_control} + @click=${(ev: Event) => { + if (this.view && this.view.queryResults) { dispatchFrigateCardEvent( this, 'thumbnail-carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, + queryResults: this.view.queryResults.clone().selectResult(index), }, ); } @@ -258,6 +206,7 @@ export class FrigateCardThumbnailCarousel extends LitElement { return html` diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 30450b60..1625056c 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -1,5 +1,12 @@ -import { format, fromUnixTime } from 'date-fns'; -import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import format from 'date-fns/format'; +import { + CSSResult, + html, + LitElement, + PropertyValues, + 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'; @@ -7,19 +14,24 @@ 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 { + errorToConsole, + formatDateAndTime, + getDurationString, + prettifyTitle, +} from '../utils/basic.js'; import { renderTask } from '../utils/task.js'; -import { createFetchThumbnailTask } from '../utils/thumbnail.js'; -import { View } from '../view.js'; +import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js'; +import { View } from '../view/view.js'; +import { Task, TaskStatus } from '@lit-labs/task'; + +import type { ExtendedHomeAssistant } from '../types.js'; +import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js'; +import { CameraManager } from '../camera-manager/manager.js'; +import { ViewMediaClassifier } from '../view/media-classifier.js'; +import { downloadMedia } from '../utils/download.js'; +import { dispatchFrigateCardErrorEvent } from './message.js'; // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; @@ -32,28 +44,80 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement { @property({ attribute: false }) public hass?: ExtendedHomeAssistant; - protected _embedThumbnailTask = createFetchThumbnailTask( - this, - () => this.hass, - () => this.thumbnail, - ); + protected _embedThumbnailTask?: Task; + + // Only load thumbnails on view in case there is a very large number of them. + protected _intersectionObserver: IntersectionObserver; + + constructor() { + super(); + this._intersectionObserver = new IntersectionObserver( + this._intersectionHandler.bind(this), + ); + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + this._intersectionObserver.observe(this); + super.connectedCallback(); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + this._intersectionObserver.disconnect(); + } + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('thumbnail')) { + this._embedThumbnailTask = createFetchThumbnailTask( + this, + () => this.hass, + () => this.thumbnail, + false, + ); + // Reset the observer so the initial intersection handler call will set + // the visibility correctly. + this._intersectionObserver.unobserve(this); + this._intersectionObserver.observe(this); + } + } + + /** + * Called when the live view intersects with the viewport. + * @param entries The IntersectionObserverEntry entries (should be only 1). + */ + protected _intersectionHandler(entries: IntersectionObserverEntry[]): void { + if ( + this._embedThumbnailTask?.status === TaskStatus.INITIAL && + entries.some((entry) => entry.isIntersecting) + ) { + this._embedThumbnailTask?.run(); + } + } protected render(): TemplateResult | void { - return html` - ${this.thumbnail - ? renderTask( - this, - this._embedThumbnailTask, - (embeddedThumbnail: string | null) => - embeddedThumbnail - ? html`` - : html`` - ) - : html` `} - `; + if (!this._embedThumbnailTask) { + return; + } + const imageOff = html` `; + + return html`${this.thumbnail + ? renderTask( + this, + this._embedThumbnailTask, + (embeddedThumbnail: string | null) => + embeddedThumbnail ? html`` : html``, + { inProgressFunc: () => imageOff }, + ) + : imageOff} `; } static get styles(): CSSResult { @@ -66,6 +130,9 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { @property({ attribute: false }) public date?: Date; + @property({ attribute: false }) + public cameraTitle?: string; + protected render(): TemplateResult | void { if (!this.date) { return; @@ -73,6 +140,7 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { return html`
${format(this.date, 'HH:mm')}
${format(this.date, 'MMM do')}
+ ${this.cameraTitle ? html`
${this.cameraTitle}
` : html``} `; } @@ -84,27 +152,97 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement { @customElement('frigate-card-thumbnail-details-event') export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) - public event?: FrigateEvent; + public media?: EventViewMedia; + + @property({ attribute: false }) + public seek?: Date; + + @property({ attribute: false }) + public cameraTitle?: string; protected render(): TemplateResult | void { - if (!this.event) { + if (!this.media) { 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)} -
+ const rawScore = this.media.getScore(); + const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null; + const rawStartTime = this.media.getStartTime(); + const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null; + + const rawEndTime = this.media.getEndTime(); + const duration = + rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null; + const inProgress = this.media.inProgress() ? localize('event.in_progress') : null; + + const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null; + const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null; + const tags = prettifyTitle(this.media.getTags()?.join(', ')) ?? null; + const whatWithTags = + what || tags ? (what ?? '') + (what && tags ? ': ' : '') + (tags ?? '') : null; + + const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null; + + return html` + ${whatWithTags + ? html`
+ ${whatWithTags} + ${score ? html`${score}` : ''} +
` + : ``} +
+ ${startTime + ? html`
+ + ${startTime} +
+ ${duration || inProgress + ? html`
+ + ${duration ? html`${duration}` : ''} + ${inProgress + ? html`${inProgress}` + : ''} +
` + : ''}` + : ''} + ${this.cameraTitle + ? html`
+ + ${this.cameraTitle} +
` + : ''} + ${where + ? html`
+ + ${where} +
` + : html``} + ${tags + ? html`
+ + ${tags} +
` + : html``} + ${seek + ? html`
+ + ${seek} +
` + : html``}
-
- ${score} -
`; + `; } static get styles(): CSSResult { @@ -115,25 +253,77 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { @customElement('frigate-card-thumbnail-details-recording') export class FrigateCardThumbnailDetailsRecording extends LitElement { @property({ attribute: false }) - public recording?: FrigateRecording; + public media?: RecordingViewMedia; + + @property({ attribute: false }) + public seek?: Date; + + @property({ attribute: false }) + public cameraTitle?: string; protected render(): TemplateResult | void { - if (!this.recording) { + if (!this.media) { return; } - return html`
-
${prettifyTitle(this.recording.camera) || ''}
- ${this.recording.seek_time + const rawStartTime = this.media.getStartTime(); + const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null; + + const rawEndTime = this.media.getEndTime(); + const duration = + rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null; + const inProgress = this.media.inProgress() ? localize('recording.in_progress') : null; + + const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null; + + const eventCount = this.media.getEventCount(); + return html` + ${this.cameraTitle + ? html`
+ ${this.cameraTitle} +
` + : ``} +
+ ${startTime ? html`
- ${localize('recording.seek')} - ${format(fromUnixTime(this.recording.seek_time), 'HH:mm:ss')} + + ${startTime} +
+ ${duration || inProgress + ? html`
+ + ${duration ? html`${duration}` : ''} + ${inProgress + ? html`${inProgress}` + : ''} +
` + : ''}` + : ''} + ${seek + ? html`
+ + ${seek}
` : html``} + ${eventCount !== null + ? html`
+ + ${eventCount} +
` + : ``}
-
- ${this.recording.events} - ${localize('recording.events')} -
`; + `; } static get styles(): CSSResult { @@ -143,6 +333,17 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { @customElement('frigate-card-thumbnail') export class FrigateCardThumbnail extends LitElement { + // HomeAssistant object may be required for thumbnail signing (for Frigate + // events). + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: true }) + public media?: ViewMedia; + @property({ attribute: true, type: Boolean }) public details = false; @@ -152,159 +353,150 @@ export class FrigateCardThumbnail extends LitElement { @property({ attribute: true, type: Boolean }) public show_timeline_control = false; - // ====================== - // Target-based interface - // ====================== - @property({ attribute: false }) - public target?: FrigateBrowseMediaSource | null; + @property({ attribute: true, type: Boolean }) + public show_download_control = false; @property({ attribute: false }) - public childIndex?: number; + public seek?: Date; - // =================================================== - // 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) { + if (!this.media || !this.cameraManager || !this.hass) { return; } + const thumbnail = this.media.getThumbnail(); + const title = this.media.getTitle() ?? ''; + const starClasses = { star: true, - starred: !!event?.retain_indefinitely, + starred: !!this.media?.isFavorite(), }; - return html` ${event - ? html`` - : html``} - ${this.show_favorite_control && event && this.hass && this.clientID - ? html` ` + : ViewMediaClassifier.isRecording(this.media) + ? html`` + : html``} + ${shouldShowFavoriteControl + ? html` { + @click=${async (ev: Event) => { 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); - }); + if (this.hass && this.media) { + try { + await this.cameraManager?.favoriteMedia( + this.hass, + this.media, + !this.media?.isFavorite(), + ); + } catch (e) { + errorToConsole(e as Error); + return; + } + this.requestUpdate(); } }} />` - : ``} - ${this.details && event - ? html`` - : this.details && recording - ? html`` - : html``} - ${this.show_timeline_control - ? html` { - stopEventFromActivatingCardWideActions(ev); - if (event) { + : ``} + ${this.details && ViewMediaClassifier.isEvent(this.media) + ? html`` + : this.details && ViewMediaClassifier.isRecording(this.media) + ? html`` + : html``} + ${shouldShowTimelineControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (!this.view || !this.media) { + return; + } this.view - ?.evolve({ + .evolve({ view: 'timeline', - target: this.target, - childIndex: this.childIndex ?? null, + queryResults: this.view.queryResults + ?.clone() + .selectResultIfFound((media) => media === this.media), }) .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); - } - }} - >` - : ''}`; + }} + >` + : ''} + ${shouldShowDownloadControl + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (this.hass && this.cameraManager && this.media) { + try { + await downloadMedia(this.hass, this.cameraManager, this.media); + } catch (error: unknown) { + dispatchFrigateCardErrorEvent(this, error); + } + } + }} + >` + : ``} + `; } /** diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts new file mode 100644 index 00000000..18eff32e --- /dev/null +++ b/src/components/timeline-core.ts @@ -0,0 +1,1260 @@ +import add from 'date-fns/add'; +import differenceInSeconds from 'date-fns/differenceInSeconds'; +import endOfDay from 'date-fns/endOfDay'; +import endOfHour from 'date-fns/endOfHour'; +import startOfDay from 'date-fns/startOfDay'; +import startOfHour from 'date-fns/startOfHour'; +import sub from 'date-fns/sub'; +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import isEqual from 'lodash-es/isEqual'; +import throttle from 'lodash-es/throttle'; +import { ViewContext } from 'view'; +import { DataSet } from 'vis-data/esnext'; +import type { DataGroupCollectionType, DateType, IdType } from 'vis-timeline/esnext'; +import { + Timeline, + TimelineEventPropertiesResult, + TimelineItem, + TimelineOptions, + TimelineOptionsCluster, + TimelineWindow, +} from 'vis-timeline/esnext'; +import { CameraManager } from '../camera-manager/manager'; +import { rangesOverlap } from '../camera-manager/range'; +import { MediaQuery } from '../camera-manager/types'; +import { localize } from '../localize/localize'; +import timelineCoreStyle from '../scss/timeline-core.scss'; +import { + CameraConfig, + CardWideConfig, + ExtendedHomeAssistant, + frigateCardConfigDefaults, + FrigateCardView, + ThumbnailsControlBaseConfig, + TimelineCoreConfig, +} from '../types'; +import { stopEventFromActivatingCardWideActions } from '../utils/action'; +import { + contentsChanged, + dispatchFrigateCardEvent, + formatDateAndTime, + isHoverableDevice, + setOrRemoveAttribute, +} from '../utils/basic'; +import { + createQueriesForRecordingsView, + executeMediaQueryForView, + findBestMediaIndex, +} from '../utils/media-to-view'; +import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; +import { ViewMedia } from '../view/media'; +import { ViewMediaClassifier } from '../view/media-classifier'; +import { EventMediaQueries, MediaQueries } from '../view/media-queries'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; +import { View } from '../view/view'; +import './date-picker.js'; +import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; +import { dispatchMessageEvent } from './message.js'; +import './thumbnail.js'; + +interface FrigateCardGroupData { + id: string; + content: string; +} + +interface TimelineRangeChange extends TimelineWindow { + event: Event & { additionalEvent?: string }; + byUser: boolean; +} + +interface TimelineViewContext { + window?: TimelineWindow; + panBehavior?: TimelinePanBehavior; +} + +type TimelineItemClickAction = 'play' | 'select'; +type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media'; + +declare module 'view' { + interface ViewContext { + timeline?: TimelineViewContext; + } +} + +interface ExtendedTimeline extends Timeline { + // setCustomTimeMarker currently missing from Timeline types. + setCustomTimeMarker?(time: DateType, id?: IdType): void; +} + +// An event used to fetch data required for thumbnail rendering. See special +// note below on why this is necessary. +interface ThumbnailDataRequest { + item: IdType; + hass?: ExtendedHomeAssistant; + cameraManager?: CameraManager; + cameraConfig?: CameraConfig; + media?: ViewMedia; + view?: View; +} + +class ThumbnailDataRequestEvent extends CustomEvent {} + +const TIMELINE_TARGET_BAR_ID = 'target_bar'; + +/** + * 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 item?: IdType; + + @property({ attribute: true, type: Boolean }) + public details = false; + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.item) { + return html``; + } + + /* Special note on what's going on here: + * + * This component does not have access to a variety of properties required + * to render a thumbnail component, as there's no way to pass them 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 dataRequest: ThumbnailDataRequest = { + item: this.item, + }; + this.dispatchEvent( + new ThumbnailDataRequestEvent(`frigate-card:timeline:thumbnail-data-request`, { + composed: true, + bubbles: true, + detail: dataRequest, + }), + ); + + if ( + !dataRequest.hass || + !dataRequest.cameraManager || + !dataRequest.cameraConfig || + !dataRequest.media || + !dataRequest.view + ) { + return html``; + } + + return html` + `; + } +} + +@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, hasChanged: contentsChanged }) + public timelineConfig?: TimelineCoreConfig; + + @property({ attribute: true, type: Boolean }) + public thumbnailConfig?: ThumbnailsControlBaseConfig; + + // Whether or not this is a mini-timeline (in mini-mode the component takes a + // supportive role for other views). + @property({ attribute: true, type: Boolean, reflect: true }) + public mini = false; + + // Which cameraIDs to include in the timeline. If not specified, all cameraIDs + // are shown. + @property({ attribute: false, hasChanged: contentsChanged }) + public cameraIDs?: Set; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public itemClickAction?: TimelineItemClickAction; + + @state() + protected _panBehavior: TimelinePanBehavior = 'seek'; + + protected _targetBarVisible = false; + + protected _refDatePicker: Ref = createRef(); + protected _refTimeline: Ref = createRef(); + protected _timeline?: ExtendedTimeline; + + protected _timelineSource: TimelineDataSource | null = null; + + // 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: + | (TimelineEventPropertiesResult & { window?: TimelineWindow }) + | null = null; + protected _ignoreClick = false; + + protected readonly _isHoverableDevice = isHoverableDevice(); + + // Range changes are volumonous: throttle the calls on seeking. + protected _throttledSetViewDuringRangeChange = throttle( + this._setViewDuringRangeChange.bind(this), + 1000 / 10, + ); + + /** + * Get a tooltip for a given timeline event. + * @param item The TimelineItem in question. + * @returns The tooltip as a string to render. + */ + protected _getTooltip(item: TimelineItem): string { + if (!this._isHoverableDevice) { + // Don't display tooltips on touch devices, they just get in the way of + // the drawer. + return ''; + } + + // 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 ` + + `; + } + + protected _handleThumbnailDataRequest(request: ThumbnailDataRequestEvent): void { + const item = request.detail.item; + const media = this._timelineSource?.dataset.get(item)?.media; + const cameraConfig = media + ? this.cameraManager?.getStore().getCameraConfigForMedia(media) ?? undefined + : undefined; + + request.detail.hass = this.hass; + request.detail.cameraConfig = cameraConfig; + request.detail.cameraManager = this.cameraManager; + request.detail.media = media; + request.detail.view = this.view; + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + const cameraIDs = this._getTimelineCameraIDs(); + + if (!this.hass || !this.view || !this.timelineConfig || !cameraIDs) { + return; + } + + const capabilities = this.cameraManager?.getAggregateCameraCapabilities(cameraIDs); + const panTitle = + this._panBehavior === 'pan' + ? localize('timeline.pan_behavior.pan') + : this._panBehavior === 'seek' + ? localize('timeline.pan_behavior.seek') + : localize('timeline.pan_behavior.seek-in-media'); + const panIcon = + this._panBehavior === 'pan' + ? 'mdi:pan-horizontal' + : this._panBehavior === 'seek' + ? 'mdi:filmstrip' + : 'mdi:lock'; + + return html` ${capabilities?.supportsTimeline + ? html`
+
+ ${this._shouldSupportSeeking() + ? html` { + this._panBehavior = + this._panBehavior === 'pan' + ? 'seek' + : this._panBehavior === 'seek' + ? 'seek-in-media' + : 'pan'; + }} + aria-label="${panTitle}" + title="${panTitle}" + > + ` + : ''} + { + this._refDatePicker.value?.open(); + }} + > + + ) => { + this._timeline?.setWindow( + startOfDay(ev.detail.date), + endOfDay(ev.detail.date), + ); + }} + > + +
+
` + : ''}`; + } + + /** + * Get all the keys of the cameras in scope for this timeline. + * @returns A set of camera ids (may be empty). + */ + protected _getTimelineCameraIDs(): Set | null { + return ( + this.cameraIDs ?? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null + ); + } + + /** + * Called whenever the range is in the process of being changed. + * @param properties + */ + protected _timelineRangeChangeHandler(properties: TimelineRangeChange): void { + if (this._pointerHeld) { + this._ignoreClick = true; + } + + if ( + this._shouldSupportSeeking() && + this._timeline && + properties.byUser && + // Do not adjust select/seek media during zoom events. + properties.event.type !== 'wheel' && + properties.event.additionalEvent !== 'pinchin' && + properties.event.additionalEvent !== 'pinchout' + ) { + const targetTime = this._pointerHeld?.window + ? add(properties.start, { + seconds: + (this._pointerHeld.time.getTime() - + this._pointerHeld.window.start.getTime()) / + 1000, + }) + : properties.end; + + if (this._pointerHeld) { + this._setTargetBarAppropriately(targetTime); + } + + this._throttledSetViewDuringRangeChange(targetTime, properties); + } + } + + protected _shouldSupportSeeking(): boolean { + return this.mini; + } + + /** + * Set the target bar at a given time. + * @param targetTime + */ + protected _setTargetBarAppropriately(targetTime: Date): void { + if (!this._timeline) { + return; + } + + const targetBarOn = + this._shouldSupportSeeking() && + (this._panBehavior === 'seek' || + (this._panBehavior === 'seek-in-media' && + this._timeline.getSelection().some((id) => { + const item = this._timelineSource?.dataset?.get(id); + return ( + item && + item.start && + item.end && + targetTime.getTime() >= item.start && + targetTime.getTime() <= item.end + ); + }))); + + if (targetBarOn) { + if (!this._targetBarVisible) { + this._timeline?.addCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); + this._targetBarVisible = true; + } else { + this._timeline?.setCustomTime(targetTime, TIMELINE_TARGET_BAR_ID); + } + + const window = this._timeline.getWindow(); + const markerProportion = + (targetTime.getTime() - window.start.getTime()) / + (window.end.getTime() - window.start.getTime()); + + // Position the marker proportionally to how 'far' the pointer is being + // held relative to the timeline window. + this.setAttribute( + 'target-bar-marker-direction', + markerProportion < 0.25 ? 'right' : markerProportion > 0.75 ? 'left' : 'center', + ); + this._timeline?.setCustomTimeMarker?.( + formatDateAndTime(targetTime, true), + TIMELINE_TARGET_BAR_ID, + ); + } else { + this._removeTargetBar(); + } + } + + /** + * Remove the target bar. + */ + protected _removeTargetBar(): void { + this.removeAttribute('target-bar-direction'); + if (this._targetBarVisible) { + this._timeline?.removeCustomTime(TIMELINE_TARGET_BAR_ID); + this._targetBarVisible = false; + } + } + + /** + * Set the view during a range change. + * @param targetTime The target time. + * @param properties The range change properties. + * @returns + */ + protected async _setViewDuringRangeChange( + targetTime: Date, + properties: TimelineRangeChange, + ): Promise { + const results = this.view?.queryResults; + const media = results?.getResults(); + if ( + !media || + !results || + !this._timeline || + !this.view || + !this.hass || + !this.cameraManager || + this._panBehavior === 'pan' + ) { + return; + } + + const canSeek = this._shouldSupportSeeking(); + const newResults = + this._panBehavior === 'seek-in-media' + ? null + : results + .clone() + .resetSelectedResult() + .selectBestResult((media) => findBestMediaIndex(media, targetTime)); + + const desiredView: FrigateCardView = this.mini + ? targetTime >= new Date() + ? 'live' + : 'media' + : this.view.view; + + this.view + .evolve({ + view: desiredView, + ...(newResults && + newResults.hasSelectedResult() && { queryResults: newResults }), + }) // Whether or not to set the timeline window. + .mergeInContext({ + ...(canSeek && { mediaViewer: { seek: targetTime } }), + ...this._getTimelineContext({ start: properties.start, end: properties.end }), + }) + .dispatchChangeEvent(this); + } + + /** + * Called whenever the timeline is clicked. + * @param properties The properties of the timeline click event. + */ + protected async _timelineClickHandler( + properties: TimelineEventPropertiesResult, + ): Promise { + // Calls to stopEventFromActivatingCardWideActions() are included for + // completeness. Timeline does not support card-wide events and they are + // disabled in card.ts in `_getMergedActions`. + if ( + this._ignoreClick || + (properties.what && + ['item', 'background', 'group-label', 'axis'].includes(properties.what)) + ) { + stopEventFromActivatingCardWideActions(properties.event); + } + + const timelineCameraIDs = this._getTimelineCameraIDs(); + if ( + this._ignoreClick || + !this.hass || + !this._timeline || + !this.view || + !this.cameraManager || + !this.cardWideConfig || + !timelineCameraIDs || + !properties.what + ) { + return; + } + + let view: View | null = null; + let drawerAction: 'open' | 'close' = 'close'; + + if ( + this.timelineConfig?.show_recordings && + ['background', 'group-label'].includes(properties.what) + ) { + const cameraIDs = properties.group + ? new Set([String(properties.group)]) + : this._getTimelineCameraIDs(); + const query = cameraIDs + ? createQueriesForRecordingsView( + this.cameraManager, + this.cardWideConfig, + cameraIDs, + ) + : null; + if (query) { + view = await executeMediaQueryForView( + this, + this.hass, + this.cameraManager, + this.view, + query, + { + targetView: 'recording', + targetTime: + properties.what === 'background' + ? properties.time + : this._timeline.getWindow().end, + select: 'time', + }, + ); + } + } else if (this.timelineConfig?.show_recordings && properties.what === 'axis') { + const query = createQueriesForRecordingsView( + this.cameraManager, + this.cardWideConfig, + timelineCameraIDs, + { + start: startOfHour(properties.time), + end: endOfHour(properties.time), + }, + ); + if (query) { + view = await executeMediaQueryForView( + this, + this.hass, + this.cameraManager, + this.view, + query, + { + targetView: 'recording', + targetTime: properties.time, + select: 'time', + }, + ); + } + } else if (properties.item && properties.what === 'item') { + const newResults = this.view.queryResults + ?.clone() + .resetSelectedResult() + .selectResultIfFound((media) => media.getID() === properties.item); + + if (!newResults || !newResults.hasSelectedResult()) { + // This can happen in a few situations: + // - If this is a recording query (with recorded hours) and an event is + // clicked on the timeline + // - If the current thumbnails/results is a filtered view from the media + // gallery (i.e. any case where the thumbnails may not be match the + // events on the timeline, e.g. in the snapshots viewer but + // mini-timeline showing all media). + const fullEventView = await this._createViewWithEventMediaQuery( + this._createEventMediaQuerys(), + { + selectedItem: properties.item, + targetView: 'media', + }, + ); + if (fullEventView?.queryResults?.hasResults()) { + view = fullEventView; + } + } else { + view = this.view.evolve({ + queryResults: newResults, + view: this.itemClickAction === 'play' ? 'media' : this.view.view, + }); + } + + if (view?.queryResults?.hasResults()) { + view.mergeInContext({ mediaViewer: { seek: properties.time } }); + } + view?.mergeInContext(this._getTimelineContext()); + + if (this.itemClickAction === 'select' && view) { + drawerAction = 'open'; + } + } + + if (view) { + view.dispatchChangeEvent(this); + } + dispatchFrigateCardEvent(this, `thumbnails:${drawerAction}`); + + this._ignoreClick = false; + } + + /** + * Get a broader prefetch window from a start and end basis. + * @param window The window to broaden. + * @returns A broader timeline. + */ + protected _getPrefetchWindow(window: TimelineWindow): TimelineWindow { + const delta = differenceInSeconds(window.end, window.start); + return { + start: sub(window.start, { seconds: delta }), + end: add(window.end, { seconds: delta }), + }; + } + + /** + * Handle a range change in the timeline. + * @param properties vis.js provided range information. + */ + protected async _timelineRangeChangedHandler(properties: { + start: Date; + end: Date; + byUser: boolean; + event: Event & { additionalEvent: string }; + }): Promise { + if (!properties.byUser) { + return; + } + this._removeTargetBar(); + + if (!this.hass) { + return; + } + + const prefetchedWindow = this._getPrefetchWindow(properties); + await this._timelineSource?.refresh(this.hass, prefetchedWindow); + + // Don't show event thumbnails if the user is looking at recordings, + // as the recording "hours" are the media, not the event + // clips/snapshots. + if ( + this._timeline && + this.view && + !MediaQueriesClassifier.areRecordingQueries(this.view.query) + ) { + const newView = await this._createViewWithEventMediaQuery( + this._createEventMediaQuerys({ window: this._timeline.getWindow() }), + ); + + // Specifically avoid dispatching new results on range change unless there + // is something to be gained by doing so. Example usecase: On initial view + // load in mini timeline mode, the first 50 events are fetched -- the + // first drag of the timeline should not dispatch new results unless + // something is actually useful (as otherwise it creates a visible + // 'flicker' for the user as the viewer reloads all the media). + const newResults = newView?.queryResults; + if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) { + newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); + } + } + } + + protected _createEventMediaQuerys(options?: { + window?: TimelineWindow; + }): EventMediaQueries | null { + if (!this._timeline || !this._timelineSource || !this.cardWideConfig) { + return null; + } + + const cacheFriendlyWindow = this._timelineSource.getCacheFriendlyEventWindow( + options?.window ?? this._timeline.getWindow(), + ); + + const eventQueries = + this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); + if (!eventQueries) { + return null; + } + return new EventMediaQueries(eventQueries); + } + + protected async _createViewWithEventMediaQuery( + query: EventMediaQueries | null, + options?: { + targetView?: FrigateCardView; + selectedItem?: IdType; + }, + ): Promise { + if (!this.hass || !this.cameraManager || !this.view || !query) { + return null; + } + const view = await executeMediaQueryForView( + this, + this.hass, + this.cameraManager, + this.view, + query, + { + targetView: options?.targetView, + select: 'latest', + }, + ); + if (!view) { + return null; + } + if (options?.selectedItem) { + view.queryResults?.selectResultIfFound( + (media) => media.getID() === options.selectedItem, + ); + } else { + // If not asked to select a new item, persist the currently selected item + // if possible. + const currentlySelectedResult = this.view.queryResults?.getSelectedResult(); + if (currentlySelectedResult) { + view.queryResults?.selectResultIfFound( + (media) => media.getID() === currentlySelectedResult.getID(), + ); + } + } + return view; + } + + /** + * Build the visjs dataset to render on the timeline. + * @returns The dataset. + */ + protected _getGroups(): DataGroupCollectionType { + const groups: FrigateCardGroupData[] = []; + (this._getTimelineCameraIDs() ?? []).forEach((cameraID) => { + if (!this.hass || !this.cameraManager) { + return; + } + const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID); + const cameraCapabilities = this.cameraManager.getCameraCapabilities(cameraID); + + if (cameraMetadata && cameraCapabilities?.supportsTimeline) { + groups.push({ + id: cameraID, + content: cameraMetadata.title, + }); + } + }); + return new DataSet(groups); + } + + protected _getPerfectWindowFromMedia(media: ViewMedia): TimelineWindow | null { + const startTime = media.getStartTime(); + const endTime = media.getEndTime(); + + if (ViewMediaClassifier.isEvent(media)) { + const windowSeconds = this._getConfiguredWindowSeconds(); + + if (startTime && endTime) { + if (endTime.getTime() - startTime.getTime() > windowSeconds * 1000) { + // If the event is larger than the configured window, only show the most + // recent portion of the event that fits in the window. + return { + start: sub(endTime, { seconds: windowSeconds }), + end: endTime, + }; + } else { + // If the event is shorter than the configured window, center the event + // in the window. + const gap = windowSeconds - (endTime.getTime() - startTime.getTime()) / 1000; + return { + start: sub(startTime, { seconds: gap / 2 }), + end: add(endTime, { seconds: gap / 2 }), + }; + } + } else if (startTime) { + // If there's no end-time yet, place the start-time in the center of the + // time window. + return { + start: sub(startTime, { seconds: windowSeconds / 2 }), + end: add(startTime, { seconds: windowSeconds / 2 }), + }; + } + } else if (ViewMediaClassifier.isRecording(media) && startTime && endTime) { + return { + start: startTime, + end: endTime, + }; + } + return null; + } + + /** + * 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 _getDefaultStartEnd(): TimelineWindow { + const end = new Date(); + const start = sub(end, { + seconds: this._getConfiguredWindowSeconds(), + }); + return { start: start, end: end }; + } + + /** + * Determine if the timeline should use clustering. + * @returns `true` if the timeline should cluster, `false` otherwise. + */ + protected _isClustering(): boolean { + return ( + this.timelineConfig?.style === 'stack' && + !!this.timelineConfig?.clustering_threshold && + this.timelineConfig.clustering_threshold > 0 + ); + } + + /** + * Get timeline options. + */ + protected _getOptions(): TimelineOptions | null { + if (!this.timelineConfig) { + return null; + } + + const defaultWindow = this._getDefaultStartEnd(); + const stack = this.timelineConfig.style === 'stack'; + // 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 => { + const media = this.view?.queryResults?.getSelectedResult(); + const selectedId = media?.getID(); + const firstMedia = (first).media; + const secondMedia = (second).media; + + // Never include the currently selected item in a cluster, and + // never group different object types together (e.g. person and + // car). + return ( + first.type !== 'background' && + first.type === second.type && + first.id !== selectedId && + second.id !== selectedId && + !!firstMedia && + !!secondMedia && + ViewMediaClassifier.isEvent(firstMedia) && + ViewMediaClassifier.isEvent(secondMedia) && + firstMedia.isGroupableWith(secondMedia) + ); + }, + } + : // Timeline type information is incorrect requiring this 'as'. + (false as unknown as TimelineOptionsCluster), + minHeight: '100%', + maxHeight: '100%', + zoomMax: 1 * 24 * 60 * 60 * 1000, + zoomMin: 1 * 1000, + margin: { + item: { + // In ribbon mode, a 20px item is reduced to 6px, so need to add a + // 14px margin to ensure items line up with subgroups. + vertical: stack ? 10 : 24, + }, + }, + selectable: true, + stack: stack, + start: defaultWindow.start, + end: defaultWindow.end, + groupHeightMode: 'auto', + tooltip: { + followMouse: true, + overflowMethod: 'cap', + template: this._getTooltip.bind(this), + }, + xss: { + disabled: false, + filterOptions: { + whiteList: { + 'frigate-card-timeline-thumbnail': ['details', 'item'], + 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.cameraManager; + } + + /** + * Update the timeline from the view object. + */ + protected async _updateTimelineFromView(): Promise { + if ( + !this.hass || + !this.view || + !this.timelineConfig || + !this._timelineSource || + !this._timeline + ) { + return; + } + + const timelineWindow = this._timeline.getWindow(); + + // Calculate the timeline window to show. If there is a window set in the + // view context, always honor that. Otherwise, if there's a selected media + // item that is already within the current window (even if it's not + // perfectly positioned) -- leave it as is. Otherwise, change the window to + // perfectly center on the media. + + let desiredWindow = timelineWindow; + const media = this.view.queryResults?.getSelectedResult(); + const mediaStartTime = media?.getStartTime(); + const mediaEndTime = media?.getEndTime(); + const mediaWindow: TimelineWindow | null = + media && mediaStartTime + ? // If this media has no end time, it's just a "point" in time so the + // range effectively starts/ends at the same time. + { start: mediaStartTime, end: mediaEndTime ?? mediaStartTime } + : null; + const context = this.view.context?.timeline; + + if (context && context.panBehavior) { + this._panBehavior = context.panBehavior; + } + + if (context && context.window) { + desiredWindow = context.window; + } else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { + const perfectMediaWindow = this._getPerfectWindowFromMedia(media); + if (perfectMediaWindow) { + desiredWindow = perfectMediaWindow; + } + } + const prefetchedWindow = this._getPrefetchWindow(desiredWindow); + + if (!this._pointerHeld) { + // Don't fetch any data or touch the timeline in any way if the user is + // currently interacting with it. Without this the subsequent data fetches + // (via fetchIfNecessary) may update the timeline contents which causes + // the visjs timeline to stop dragging/panning operations which is very + // disruptive to the user. + await this._timelineSource?.refresh(this.hass, prefetchedWindow); + } + + const mediaID = media?.getID(); + if (media && mediaID && 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. Only do this when the pointer is not held to avoid + // interrupting the user and to make the timeline smoother. + + // Need to this rewrite prior to setting the selection (just below), or + // the selection will be lost on rewrite. + this._timelineSource?.rewriteEvent(mediaID); + } + + const desiredId = + !!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null; + if (desiredId) { + this._timeline?.setSelection([desiredId], { + focus: false, + animation: { + animation: false, + zoom: false, + }, + }); + } + + // Set the timeline window if necessary. + if (!this._pointerHeld && !isEqual(desiredWindow, timelineWindow)) { + this._timeline.setWindow(desiredWindow.start, desiredWindow.end); + } + + // Only generate thumbnails if the existing query is not an acceptable + // match, 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] + // + // Also don't generate thumbnails in mini-timelines (they will already have + // been generated), or if the view is for recordings (media thumbnails are + // recordings, not events in this case). + + const freshMediaQuery = this._createEventMediaQuerys({ + window: desiredWindow, + }); + + if ( + !this.mini && + !MediaQueriesClassifier.areRecordingQueries(this.view.query) && + freshMediaQuery && + !this._alreadyHasAcceptableMediaQuery(freshMediaQuery) + ) { + (await this._createViewWithEventMediaQuery(freshMediaQuery)) + ?.mergeInContext(this._getTimelineContext(desiredWindow)) + .dispatchChangeEvent(this); + } + } + + protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean { + const currentQueries = this.view?.query?.getQueries(); + const currentResultTimestamp = this.view?.queryResults?.getResultsTimestamp(); + + return ( + !!this.cameraManager && + !!currentQueries && + !!currentResultTimestamp && + isEqual(currentQueries, freshMediaQuery.getQueries()) && + this.cameraManager.areMediaQueriesResultsFresh( + currentQueries, + currentResultTimestamp, + ) + ); + } + + /** + * Generate the context for timeline views. + * @returns The TimelineViewContext object. + */ + protected _getTimelineContext(window?: TimelineWindow): ViewContext { + const newWindow = window ?? this._timeline?.getWindow(); + return { + timeline: { + ...this.view?.context?.timeline, + panBehavior: this._panBehavior, + ...(newWindow && { window: newWindow }), + }, + }; + } + + /** + * Called when an update will occur. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('thumbnailConfig')) { + if (this.thumbnailConfig) { + this.style.setProperty( + '--frigate-card-thumbnail-size', + `${this.thumbnailConfig.size}px`, + ); + } else { + this.style.removeProperty('--frigate-card-thumbnail-size'); + } + } + + if (changedProps.has('timelineConfig')) { + setOrRemoveAttribute(this, !!this.timelineConfig?.show_recordings, 'recordings'); + setOrRemoveAttribute(this, this.timelineConfig?.style === 'ribbon', 'ribbon'); + setOrRemoveAttribute(this, this.timelineConfig?.style === 'stack', 'stack'); + } + + if ( + changedProps.has('cameraManager') || + changedProps.has('cameras') || + changedProps.has('timelineConfig') || + changedProps.has('cameraIDs') + ) { + const cameraIDs = this._getTimelineCameraIDs(); + if (cameraIDs && this.cameraManager && this.timelineConfig) { + this._timelineSource = new TimelineDataSource( + this.cameraManager, + cameraIDs, + this.timelineConfig.media, + this.timelineConfig.show_recordings, + ); + } else { + this._timelineSource = null; + } + } + } + + /** + * Destroy/reset the timeline. + */ + protected _destroy(): void { + this._timeline?.destroy(); + this._timeline = undefined; + this._targetBarVisible = false; + this._pointerHeld = null; + } + + /** + * 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') || changedProperties.has('cameraManager')) { + this._destroy(); + } + + let createdTimeline = false; + + if ( + this._timelineSource && + this._refTimeline.value && + this.timelineConfig && + (changedProperties.has('timelineConfig') || changedProperties.has('cameraIDs')) + ) { + if (this._timeline) { + this._destroy(); + } + + const groups = this._getGroups(); + if (!groups.length) { + if (!this.mini) { + // Don't show an empty timeline, show a message instead. + dispatchMessageEvent(this, localize('error.timeline_no_cameras'), 'info', { + icon: 'mdi:chart-gantt', + }); + } + return; + } + + const options = this._getOptions(); + if (options) { + createdTimeline = true; + const noGroups = this.mini && groups.length === 1; + if (noGroups) { + // In a mini timeline, if there's only one group don't bother grouping + // at all. + this._timeline = new Timeline( + this._refTimeline.value, + this._timelineSource.dataset, + options, + ) as Timeline; + } else { + this._timeline = new Timeline( + this._refTimeline.value, + this._timelineSource.dataset, + groups, + options, + ) as Timeline; + } + setOrRemoveAttribute(this, !noGroups, 'groups'); + + this._timeline.on('rangechanged', this._timelineRangeChangedHandler.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', (ev: TimelineEventPropertiesResult) => { + const window = this._timeline?.getWindow(); + this._pointerHeld = { + ...ev, + ...(window && { window: window }), + }; + this._ignoreClick = false; + }); + this._timeline.on('mouseUp', () => { + this._pointerHeld = null; + this._removeTargetBar(); + }); + } + } + + if (createdTimeline) { + // If the timeline was just created, give it one frame to draw itself. + // Failure to do so may result in subsequent calls to + // `this._timeline.setwindow()` being entirely ignored. Example case: + // Clicking the timeline control on a recording thumbnail. + window.requestAnimationFrame(this._updateTimelineFromView.bind(this)); + } else 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; + } +} diff --git a/src/components/timeline.ts b/src/components/timeline.ts index ca009e4e..f70a7db1 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,481 +1,11 @@ -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 { CSSResultGroup, html, LitElement, 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` - `; - } -} +import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types'; +import { CameraManager } from '../camera-manager/manager'; +import { View } from '../view/view'; +import './surround.js'; +import './timeline-core.js'; @customElement('frigate-card-timeline') export class FrigateCardTimeline extends LitElement { @@ -486,10 +16,13 @@ export class FrigateCardTimeline extends LitElement { public view?: Readonly; @property({ attribute: false }) - public cameras?: Map; + public timelineConfig?: TimelineConfig; @property({ attribute: false }) - public timelineConfig?: TimelineConfig; + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; /** * Master render method. @@ -500,20 +33,20 @@ export class FrigateCardTimeline extends LitElement { return html``; } - return html` + return html` - `; + `; } /** @@ -524,824 +57,8 @@ export class FrigateCardTimeline extends LitElement { } } -@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 bdd524e1..16f14c9e 100644 --- a/src/components/title-control.ts +++ b/src/components/title-control.ts @@ -1,7 +1,6 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { customElement, property } from 'lit/decorators.js'; - import { TitleControlConfig } from '../types.js'; import titleStyle from '../scss/title-control.scss'; @@ -21,6 +20,9 @@ export class FrigateCardTitleControl extends LitElement { @property({ attribute: false }) public fitInto?: HTMLElement; + @property({ attribute: false }) + public logo?: string; + protected _toastRef: Ref = createRef(); /** @@ -44,6 +46,7 @@ export class FrigateCardTitleControl extends LitElement { .text="${this.text}" .fitInto=${this.fitInto} > + ${this.logo ? html`` : ''} `; } @@ -58,7 +61,7 @@ export class FrigateCardTitleControl extends LitElement { /** * Show the toast. */ - public hide(): void { + public hide(): void { if (this._toastRef.value) { // Set it to false first, to ensure the timer resets. this._toastRef.value.opened = false; @@ -85,7 +88,7 @@ export class FrigateCardTitleControl extends LitElement { } declare global { - interface HTMLElementTagNameMap { - "frigate-card-title-control": FrigateCardTitleControl - } + interface HTMLElementTagNameMap { + 'frigate-card-title-control': FrigateCardTitleControl; + } } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 6518a123..3a8f8d72 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -1,5 +1,4 @@ -import { Task } from '@lit-labs/task'; -import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; +import { EmblaPluginType } from 'embla-carousel'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { CSSResultGroup, @@ -9,19 +8,15 @@ import { TemplateResult, unsafeCSS, } from 'lit'; -import { guard } from 'lit/directives/guard.js'; 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 { renderProgressIndicator } from '../components/message.js'; -import viewerStyle from '../scss/viewer.scss'; +import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; import viewerCarouselStyle from '../scss/viewer-carousel.scss'; +import viewerProviderStyle from '../scss/viewer-provider.scss'; +import viewerStyle from '../scss/viewer.scss'; import { - BrowseMediaNeighbors, - BrowseMediaQueryParameters, - CameraConfig, + CardWideConfig, ExtendedHomeAssistant, - FrigateBrowseMediaSource, frigateCardConfigDefaults, FrigateCardMediaPlayer, MediaLoadedInfo, @@ -29,32 +24,49 @@ import { ViewerConfig, } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; -import { contentsChanged } from '../utils/basic.js'; -import { - fetchLatestMediaAndDispatchViewChange, - getEventStartTime, - getFullDependentBrowseMediaQueryParametersOrDispatchError, - isTrueMedia, - multipleBrowseMediaQueryMerged, - overrideMultiBrowseMediaQueryParameters, -} from '../utils/ha/browse-media.js'; +import { contentsChanged, errorToConsole } from '../utils/basic.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; -import { View } from '../view.js'; +import { View } from '../view/view.js'; +import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { FrigateCardMediaCarousel, - IMG_EMPTY, - wrapRawMediaLoadedEventForCarousel, wrapMediaLoadedEventForCarousel, } from './media-carousel.js'; +import type { CarouselSelect } from './carousel.js'; import './next-prev-control.js'; import './title-control.js'; import '../patches/ha-hls-player'; -import './surround-thumbnails'; -import { EmblaCarouselPlugins } from './carousel.js'; -import { renderTask } from '../utils/task.js'; +import './surround.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; +import { CameraManager } from '../camera-manager/manager.js'; +import { + changeViewToRecentEventsForCameraAndDependents, + changeViewToRecentRecordingForCameraAndDependents, +} from '../utils/media-to-view.js'; +import { VideoContentType, ViewMedia } from '../view/media.js'; +import { ViewMediaClassifier } from '../view/media-classifier'; +import { guard } from 'lit/directives/guard.js'; +import { localize } from '../localize/localize.js'; +import { MediaQueriesResults } from '../view/media-queries-results.js'; +import { canonicalizeHAURL } from '../utils/ha/index.js'; +import { dispatchMediaLoadedEvent } from '../utils/media-info.js'; +import { playMediaMutingIfNecessary } from '../utils/media.js'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, +} from '../utils/media.js'; + +export interface MediaViewerViewContext { + seek?: Date; +} + +declare module 'view' { + interface ViewContext { + mediaViewer?: MediaViewerViewContext; + } +} @customElement('frigate-card-viewer') export class FrigateCardViewer extends LitElement { @@ -68,64 +80,79 @@ export class FrigateCardViewer extends LitElement { public viewerConfig?: ViewerConfig; @property({ attribute: false }) - public cameras?: Map; + public resolvedMediaCache?: ResolvedMediaCache; @property({ attribute: false }) - public resolvedMediaCache?: ResolvedMediaCache; + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameras || !this.viewerConfig) { + if ( + !this.hass || + !this.view || + !this.viewerConfig || + !this.cameraManager || + !this.cardWideConfig + ) { return; } - const browseMediaQueryParameters = - getFullDependentBrowseMediaQueryParametersOrDispatchError( - this, - this.hass, - this.cameras, - this.view.camera, - ); - - if (!this.view.target) { - // 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 + if (!this.view.queryResults?.hasResults()) { + // If the query is not specified, the view must tell us which mediaType to + // search for. When the query *is* specified, the view is not required to + // indicate the media type (e.g. the mixed 'media' view from the // timeline). - const mediaType = this.view.getMediaType(); - if (!browseMediaQueryParameters || !mediaType) { + const mediaType = this.view.getDefaultMediaType(); + if (!mediaType) { return; } - fetchLatestMediaAndDispatchViewChange( - this, - this.hass, - this.view, - overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, { - mediaType: mediaType, - }), - ); - return renderProgressIndicator(); + if (mediaType === 'recordings') { + changeViewToRecentRecordingForCameraAndDependents( + this, + this.hass, + this.cameraManager, + this.cardWideConfig, + this.view, + { + targetView: 'recording', + select: 'latest', + }, + ); + } else { + changeViewToRecentEventsForCameraAndDependents( + this, + this.hass, + this.cameraManager, + this.cardWideConfig, + this.view, + { + targetView: 'media', + mediaType: mediaType, + select: 'latest', + }, + ); + } + return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); } - return html` + return html` - `; + `; } /** @@ -136,7 +163,7 @@ export class FrigateCardViewer extends LitElement { } } -const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player'; +const FRIGATE_CARD_VIEWER_PROVIDER = 'frigate-card-viewer-provider'; @customElement('frigate-card-viewer-carousel') export class FrigateCardViewerCarousel extends LitElement { @@ -154,85 +181,33 @@ export class FrigateCardViewerCarousel extends LitElement { @property({ attribute: false, hasChanged: contentsChanged }) public viewerConfig?: ViewerConfig; - @property({ attribute: false }) - public browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null; - @property({ attribute: false }) public resolvedMediaCache?: ResolvedMediaCache; + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + 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< - [FrigateBrowseMediaSource | null | undefined], - void - >( - this, - async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise => { - for ( - let i = 0; - !this.viewerConfig?.lazy_load && - this.hass && - target && - target.children && - i < (target.children || []).length; - ++i - ) { - if (isTrueMedia(target.children[i])) { - await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache); - } - } - }, - () => [this.view?.target], - ); - /** * The updated lifecycle callback for this element. * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { - const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel(); + super.updated(changedProperties); - if (frigateCardCarousel && changedProperties.has('view')) { + if (changedProperties.has('view')) { const oldView = changedProperties.get('view') as View | undefined; - if (oldView) { - 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. - frigateCardCarousel.carouselScrollTo(slide); - } - } + // Seek into the video if the seek time has changed (this is also called + // on media load, since the media may or may not have been loaded at + // this point). + if (this.view?.context?.mediaViewer !== oldView?.context?.mediaViewer) { + this._seekHandler(); } } - - super.updated(changedProperties); - } - - /** - * 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 _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) : null; } /** @@ -246,18 +221,6 @@ export class FrigateCardViewerCarousel extends LitElement { ); } - /** - * Get the Embla options to use. - * @returns An EmblaOptionsType object or undefined for no options. - */ - protected _getOptions(): EmblaOptionsType { - return { - // Start the carousel on the selected child number. - startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0, - draggable: this.viewerConfig?.draggable ?? true, - }; - } - /** * The the HLS player on a slide (or current slide if not provided.) * @param slide An optional slide. @@ -271,7 +234,9 @@ export class FrigateCardViewerCarousel extends LitElement { } return ( - (slide?.querySelector(FRIGATE_CARD_HLS_SELECTOR) as FrigateCardMediaPlayer) ?? null + (slide?.querySelector( + FRIGATE_CARD_VIEWER_PROVIDER, + ) as unknown as FrigateCardMediaPlayer) ?? null ); } @@ -282,10 +247,7 @@ export class FrigateCardViewerCarousel extends LitElement { protected _getPlugins(): EmblaPluginType[] { return [ // 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 + ...(this.view?.queryResults?.getResultsCount() ?? 0 > 1 ? [ WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel @@ -296,11 +258,11 @@ export class FrigateCardViewerCarousel extends LitElement { : []), Lazyload({ ...(this.viewerConfig?.lazy_load && { - lazyLoadCallback: this._lazyloadSlide.bind(this), + lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide), }), }), AutoMediaPlugin({ - playerSelector: FRIGATE_CARD_HLS_SELECTOR, + playerSelector: FRIGATE_CARD_VIEWER_PROVIDER, ...(this.viewerConfig?.auto_play && { autoPlayCondition: this.viewerConfig.auto_play, }), @@ -322,256 +284,92 @@ export class FrigateCardViewerCarousel extends LitElement { * @returns A BrowseMediaNeighbors with indices and objects of true media * neighbors. */ - protected _getMediaNeighbors(): BrowseMediaNeighbors | null { - if ( - !this.view || - !this.view.target || - !this.view.target.children || - this.view.childIndex === null - ) { - return null; + protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] { + const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null; + const resultCount = this.view?.queryResults?.getResultsCount() ?? 0; + if (!this.view || !this.view.queryResults || selectedIndex === null) { + return [null, null]; } - // Work backwards from the index to get the previous real media. - let prevIndex: number | null = null; - for (let i = this.view.childIndex - 1; i >= 0; i--) { - const media = this.view.target.children[i]; - if (media && isTrueMedia(media)) { - prevIndex = i; - break; - } - } - - // Work forwards from the index to get the next real media. - 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 && isTrueMedia(media)) { - nextIndex = i; - break; - } - } - - return { - previousIndex: prevIndex, - previous: prevIndex != null ? this.view.target.children[prevIndex] : null, - nextIndex: nextIndex, - next: nextIndex != null ? this.view.target.children[nextIndex] : null, - }; + const previous: ViewMedia | null = + selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null; + const next: ViewMedia | null = + selectedIndex + 1 < resultCount + ? this.view.queryResults.getResult(selectedIndex + 1) + : null; + return [previous, next]; } - /** - * Get a clip view that matches a given snapshot. Includes clips within the - * same range as the current view. - * @param snapshot The snapshot to find a matching clip for. - * @returns The view that would show the matching clip. - */ - protected async _findRelatedClipView( - snapshot: FrigateBrowseMediaSource, - ): Promise { - if ( - !this.hass || - !this.view || - !this.view.target || - !this.view.target.children || - !this.view.target.children.length || - !this.browseMediaQueryParameters - ) { - return null; - } - - const snapshotStartTime = getEventStartTime(snapshot); - if (!snapshotStartTime) { - return null; - } - - // Heuristic: At this point, the user has a particular snapshot that they - // are interested in and want to see a related clip, yet the viewer code - // does not know the exact search criteria that led to that snapshot (e.g. - // it could be a 10-deep folder in the gallery). To give the user to ability - // to 'navigate' in the clips view once they change into that mode, this - // heuristic finds the earliest and latest snapshot that the user is - // currently viewing and mirrors that range into the clips view. Then, - // within the results see if there's a clip that matches the same time as - // the snapshot. - let earliest: number | null = null; - let latest: number | null = null; - for (let i = 0; i < this.view.target.children.length; i++) { - const child = this.view.target.children[i]; - if (!isTrueMedia(child)) { - continue; - } - const startTime = getEventStartTime(child); - - if (startTime && (earliest === null || startTime < earliest)) { - earliest = startTime; - } - if (startTime && (latest === null || startTime > latest)) { - latest = startTime; - } - } - if (!earliest || !latest) { - return null; - } - - let clips: FrigateBrowseMediaSource | null; - - 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; - } - - if (!clips || !clips.children || !clips.children.length) { - return null; - } - - for (let i = 0; i < clips.children.length; i++) { - const child = clips.children[i]; - if (!isTrueMedia(child)) { - continue; - } - const clipStartTime = getEventStartTime(child); - if (clipStartTime && clipStartTime === snapshotStartTime) { - return this.view.evolve({ - view: 'clip', - target: clips, - childIndex: i, - }); - } - } - return null; + protected _setViewHandler(ev: CustomEvent): void { + this._setViewSelectedIndex(ev.detail.index); } - /** - * Handle the user selecting a new slide in the carousel. - */ - protected _setViewHandler(): void { - if (!this._refMediaCarousel.value || !this.view) { + protected _setViewSelectedIndex(index: number): void { + if (!this.view?.queryResults) { return; } - // Update the childIndex in the view. - const selected = this._refMediaCarousel.value - .frigateCardCarousel() - ?.getCarouselSelected()?.index; - if (selected !== undefined) { - const childIndex = this._slideToChild[selected]; - if (childIndex !== undefined) { - this.view - .evolve({ - childIndex: childIndex, - }) - .dispatchChangeEvent(this); - } + const selectedIndex = this.view.queryResults.getSelectedIndex(); + if (selectedIndex === null || selectedIndex === index) { + // The slide may already be selected on load, so don't dispatch a new view + // unless necessary (i.e. the new index is different from the current + // index). + return; } - } - /** - * 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); + const newResults = this.view?.queryResults?.clone().selectResult(index); + if (!newResults) { + return; } - return url; + const cameraID = newResults.getSelectedResult()?.getCameraID(); + + this.view + ?.evolve({ + queryResults: newResults, + + // Always change the camera to the owner of the selected media. + ...(cameraID && { camera: cameraID }), + }) + .dispatchChangeEvent(this); } /** * Lazy load a slide. - * @param index The index of the slide to lazy load. * @param slide The slide to lazy load. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected _lazyloadSlide(index: number, slide: HTMLElement): void { - const childIndex: number | undefined = this._slideToChild[index]; - - if ( - childIndex === undefined || - !this.hass || - !this.view || - !this.view.target || - !this.view.target.children || - !isTrueMedia(this.view.target.children[childIndex]) - ) { - return; + protected _lazyloadSlide(slide: Element): void { + if (slide instanceof HTMLSlotElement) { + slide = slide.assignedElements({ flatten: true })[0]; } - resolveMedia( - this.hass, - this.view.target.children[childIndex], - this.resolvedMediaCache, - ).then((resolvedMedia) => { - if (!resolvedMedia) { - return; - } - - // Snapshots. - const img = slide.querySelector('img') as HTMLImageElement; - - // Frigate >= 0.9.0+ clips. - const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & { - url: string; - }; - - if (img) { - img.src = this._canonicalizeHAURL(resolvedMedia.url) || ''; - } else if (hls_player) { - hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || ''; - } - }); + const viewerProvider = slide?.querySelector( + 'frigate-card-viewer-provider', + ) as FrigateCardViewerProvider | null; + if (viewerProvider) { + viewerProvider.disabled = false; + } } /** * Get slides to include in the render. - * @returns The slides to include in the render and an index keyed by slide - * number that maps to child number. + * @returns The slides to include in the render. */ - protected _getSlides(): [TemplateResult[], Record] { - if ( - !this.view || - !this.view.target || - !this.view.target.children || - !this.view.target.children.length - ) { - return [[], {}]; + protected _getSlides(): TemplateResult[] { + if (!this.view || !this.view.queryResults) { + return []; } - const slideToChild: Record = {}; const slides: TemplateResult[] = []; - for (let i = 0; i < this.view.target.children?.length; ++i) { - const slide = this._renderMediaItem(this.view.target.children[i], slides.length); - - if (slide) { - slideToChild[slides.length] = i; - slides.push(slide); + for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) { + const media = this.view.queryResults.getResult(i); + if (media) { + const slide = this._renderMediaItem(media, i); + if (slide) { + slides[i] = slide; + } } } - return [slides, slideToChild]; - } - - /** - * Determine if all the media in the carousel are resolved. - */ - protected _isMediaFullyResolved(): boolean { - for (const child of this.view?.target?.children || []) { - if (!this.resolvedMediaCache?.has(child.media_content_id)) { - return false; - } - } - return true; + return slides; } /** @@ -584,75 +382,89 @@ export class FrigateCardViewerCarousel extends LitElement { } } - /** - * Render the element, resolving the media first if necessary. - */ protected render(): TemplateResult | void { - this._slideToChild = {}; - - // If lazy loading is not enabled, wait for the media resolver task to - // complete and show a progress indictator until this. - if (!this.viewerConfig?.lazy_load && !this._isMediaFullyResolved()) { - return renderTask(this, this._mediaResolutionTask, this._render.bind(this)); + const resultCount = this.view?.queryResults?.getResultsCount() ?? 0; + if (!resultCount) { + return dispatchMessageEvent(this, localize('common.no_media'), 'info', { + icon: 'mdi:multimedia', + }); } - return this._render(); - } - /** - * Render the element. - * @returns A template to display to the user. - */ - protected _render(): TemplateResult | void { - const [slides, slideToChild] = this._getSlides(); - this._slideToChild = slideToChild; - if (!slides.length || !this.view?.media) { + // If there's no selected media, just choose the last (most recent one) to + // avoid rendering a blank. This situation should not occur in practice, as + // this view should not be called without a selected media. + const media = + this.view?.queryResults?.getSelectedResult() ?? + this.view?.queryResults?.getResult(resultCount - 1); + if ( + !this.hass || + !this.cameraManager || + !media || + !this.view || + !this.view.queryResults + ) { return; } - const neighbors = this._getMediaNeighbors(); - const [prev, next] = [neighbors?.previous, neighbors?.next]; + const [prev, next] = this._getMediaNeighbors(); - // Notes on the below: - // - guard() is used to avoid reseting the carousel unless the - // options/plugins actually change. + const scroll = (direction: 'previous' | 'next'): void => { + const currentIndex = this.view?.queryResults?.getSelectedIndex() ?? null; + if (!this.view || !this.view?.queryResults || currentIndex === null) { + return; + } + const newIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1; + if (newIndex >= 0 && newIndex < this.view.queryResults.getResultsCount()) { + this._setViewSelectedIndex(newIndex); + } + }; + + const cameraMetadata = this.cameraManager.getCameraMetadata( + this.hass, + media.getCameraID(), + ); return html` ({ + draggable: this.viewerConfig?.draggable ?? true, + }))} .carouselPlugins=${guard( - [this.viewerConfig, this.view?.target?.children?.length], + [this.viewerConfig, this.view.queryResults.getResults()], this._getPlugins.bind(this), - ) as EmblaCarouselPlugins} - .label="${this.view.media.title}" + )} + .label=${media.getTitle() ?? undefined} + .logo=${cameraMetadata?.engineLogo} .titlePopupConfig=${this.viewerConfig?.controls.title} + .selected=${this.view?.queryResults?.getSelectedIndex() ?? 0} transitionEffect=${this._getTransitionEffect()} @frigate-card:media-carousel:select=${this._setViewHandler.bind(this)} - @frigate-card:media:loaded=${this._recordingSeekHandler.bind(this)} + @frigate-card:media:loaded=${this._seekHandler.bind(this)} > { - this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious(); + scroll('previous'); stopEventFromActivatingCardWideActions(ev); }} > - ${slides} + ${guard(this.view?.queryResults?.getResults(), () => this._getSlides())} { - this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext(); + scroll('next'); stopEventFromActivatingCardWideActions(ev); }} > @@ -662,114 +474,284 @@ export class FrigateCardViewerCarousel extends LitElement { /** * 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); + protected async _seekHandler(): Promise { + const seek = this.view?.context?.mediaViewer?.seek; + const media = this.view?.queryResults?.getSelectedResult(); + if (!this.hass || !media || !seek) { + return; + } + + const seekTime = + (await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null; + const player = this._getPlayer(); + if (player && seekTime !== null) { + player.seek(seekTime); } } /** * Render a single media item in the viewer carousel. - * @param mediaToRender The FrigateBrowseMediaSource to render. - * @param slideIndex The index of the slide to render. + * @param media The ViewMedia to render. + * @param index The (slide|queryResult) index of the item to render. * @returns A rendered template. */ - protected _renderMediaItem( - mediaToRender: FrigateBrowseMediaSource, - slideIndex: number, - ): TemplateResult | void { - // Skip folders as they cannot be rendered by this viewer. + protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null { + if (!this.hass || !this.view || !this.viewerConfig) { + return null; + } + + return html`
+ ) => { + wrapMediaLoadedEventForCarousel(index, e); + }} + > +
`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(viewerCarouselStyle); + } +} + +@customElement(FRIGATE_CARD_VIEWER_PROVIDER) +export class FrigateCardViewerProvider + extends LitElement + implements FrigateCardMediaPlayer +{ + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public media?: ViewMedia; + + @property({ attribute: false }) + public viewerConfig?: ViewerConfig; + + @property({ attribute: false }) + public resolvedMediaCache?: ResolvedMediaCache; + + // 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). + @property({ attribute: false }) + public disabled = false; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + protected _refFrigateCardMediaPlayer: Ref = + createRef(); + protected _refVideoProvider: Ref = createRef(); + + public async play(): Promise { + await playMediaMutingIfNecessary( + this, + this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value, + ); + } + + public async pause(): Promise { + (this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause(); + } + + public async mute(): Promise { + if (this._refFrigateCardMediaPlayer.value) { + this._refFrigateCardMediaPlayer.value?.mute(); + } else if (this._refVideoProvider.value) { + this._refVideoProvider.value.muted = true; + } + } + + public async unmute(): Promise { + if (this._refFrigateCardMediaPlayer.value) { + this._refFrigateCardMediaPlayer.value?.mute(); + } else if (this._refVideoProvider.value) { + this._refVideoProvider.value.muted = false; + } + } + + public isMuted(): boolean { + if (this._refFrigateCardMediaPlayer.value) { + return this._refFrigateCardMediaPlayer.value?.isMuted() ?? true; + } else if (this._refVideoProvider.value) { + return this._refVideoProvider.value.muted; + } + return true; + } + + public async seek(seconds: number): Promise { + if (this._refFrigateCardMediaPlayer.value) { + return this._refFrigateCardMediaPlayer.value.seek(seconds); + } else if (this._refVideoProvider.value) { + hideMediaControlsTemporarily(this._refVideoProvider.value); + this._refVideoProvider.value.currentTime = seconds; + } + } + + /** + * Dispatch a clip view that matches the current (snapshot) query. + */ + protected async _dispatchRelatedClipView(): Promise { if ( !this.hass || !this.view || - !this.viewerConfig || - !isTrueMedia(mediaToRender) || - !['video', 'image'].includes(mediaToRender.media_content_type) + !this.cameraManager || + !this.media || + // If this specific media item has no clip, then do nothing (even if all + // the other media items do). + !ViewMediaClassifier.isEvent(this.media) || + !MediaQueriesClassifier.areEventQueries(this.view.query) ) { return; } - const lazyLoad = this.viewerConfig.lazy_load; - const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id); - if (!resolvedMedia && !lazyLoad) { + // Convert the query to a clips equivalent. + const clipQuery = this.view.query.clone(); + clipQuery.convertToClipsQueries(); + + const queries = clipQuery.getQueries(); + if (!queries) { return; } - // The media is attached to the player as '.media' which is used in - // `_selectSlideMediaShowHandler` (and not used by the player itself). - return html` -
- ${mediaToRender.media_content_type === 'video' - ? html` clipMedia.getID() === this.media?.getID(), + ); + if (!results.hasSelectedResult()) { + return; + } + + this.view + .evolve({ + view: 'media', + query: clipQuery, + queryResults: results, + }) + .dispatchChangeEvent(this); + } + + protected willUpdate(changedProps: PropertyValues): void { + const mediaContentID = this.media ? this.media.getContentID() : null; + + if ( + (changedProps.has('disabled') || + changedProps.has('media') || + changedProps.has('viewerConfig') || + changedProps.has('resolvedMediaCache') || + changedProps.has('hass')) && + this.hass && + mediaContentID && + !this.resolvedMediaCache?.has(mediaContentID) && + (!this.viewerConfig?.lazy_load || !this.disabled) + ) { + resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => { + this.requestUpdate(); + }); + } + } + + protected render(): TemplateResult | void { + if (this.disabled || !this.media || !this.hass || !this.view || !this.viewerConfig) { + return; + } + + const mediaContentID = this.media.getContentID(); + const resolvedMedia = mediaContentID + ? this.resolvedMediaCache?.get(mediaContentID) + : null; + if (!resolvedMedia) { + // Media will be resolved with the call in willUpdate() then this will be + // re-rendered. + return renderProgressIndicator({ + cardWideConfig: this.cardWideConfig, + }); + } + + return ViewMediaClassifier.isVideo(this.media) + ? this.media.getVideoContentType() === VideoContentType.HLS + ? html` + ` + : html` + ` - : html` { - if ( - this._refMediaCarousel.value - ?.frigateCardCarousel() - ?.carouselClickAllowed() - ) { - this._findRelatedClipView(mediaToRender).then((view) => { - if (view) { - view.dispatchChangeEvent(this); - } - }); - } - }} - @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 - // the previously fully loaded image -- see the note on dummy - // images in media-carousel.ts). Here we need to only call the - // media load handler on a 'real' load. - !lazyLoad || - lazyloadPlugin?.hasLazyloaded(slideIndex) - ) { - wrapRawMediaLoadedEventForCarousel(slideIndex, e); - } - }}" - />`} -
- `; + + + ` + : html` { + if (this.viewerConfig?.snapshot_click_plays_clip) { + this._dispatchRelatedClipView(); + } + }} + @load=${(e: Event) => { + dispatchMediaLoadedEvent(this, e); + }} + />`; } - /** - * Get element styles. - */ static get styles(): CSSResultGroup { - return unsafeCSS(viewerCarouselStyle); + return unsafeCSS(viewerProviderStyle); } } @@ -777,5 +759,6 @@ declare global { interface HTMLElementTagNameMap { 'frigate-card-viewer-carousel': FrigateCardViewerCarousel; 'frigate-card-viewer': FrigateCardViewer; + FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider; } } diff --git a/src/components/views.ts b/src/components/views.ts new file mode 100644 index 00000000..13a60204 --- /dev/null +++ b/src/components/views.ts @@ -0,0 +1,224 @@ +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 { CameraManager } from '../camera-manager/manager.js'; +import { ConditionState, getOverridesByKey } from '../card-condition'; +import viewsStyle from '../scss/views.scss'; +import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js'; +import { ResolvedMediaCache } from '../utils/ha/resolved-media'; +import { View } from '../view/view.js'; +import './surround.js'; + +@customElement('frigate-card-views') +export class FrigateCardViews extends LitElement { + @property({ attribute: false }) + public hass?: ExtendedHomeAssistant; + + @property({ attribute: false }) + public view?: Readonly; + + @property({ attribute: false }) + public cameraManager?: CameraManager; + + @property({ attribute: false }) + public config?: FrigateCardConfig; + + @property({ attribute: false }) + public nonOverriddenConfig?: FrigateCardConfig; + + @property({ attribute: false }) + public cardWideConfig?: CardWideConfig; + + @property({ attribute: false }) + public resolvedMediaCache?: ResolvedMediaCache; + + @property({ attribute: false }) + public conditionState?: ConditionState; + + @property({ attribute: false }) + public cameras?: ConditionState; + + @property({ attribute: false }) + public hide?: boolean; + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('view') || changedProps.has('config')) { + if (this.view?.is('live') || this._shouldLivePreload()) { + import('./live/live.js'); + } + if (this.view?.isGalleryView()) { + import('./gallery.js'); + } else if (this.view?.isViewerView()) { + import('./viewer.js'); + } else if (this.view?.is('image')) { + import('./image.js'); + } else if (this.view?.is('timeline')) { + import('./timeline.js'); + } + } + + if (changedProps.has('hide')) { + if (this.hide) { + this.setAttribute('hidden', ''); + } else { + this.removeAttribute('hidden'); + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected shouldUpdate(_: PropertyValues): boolean { + // Future: Updates to `hass` and `conditionState` here will be frequent. + // Throttling here may be necessary if users report performance degradation + // > v5.0.0-beta1 . + // + // These updates are necessary in these cases: + // - conditionState: Required to let `frigate-card-live` calculate its own + // overrides. + // - hass: Required for anything that needs to sign URLs. Of note is + // anything that renders an image (e.g. a thumbnail -- almost everything, + // or the main `frigate-card-image` view). + // + // It should instead be possible to pass conditionState to live only (every + // update required), and pass hass only once / 5 minutes (see + // HASS_REJECTION_CUTOFF_MS). + return true; + } + + protected _shouldLivePreload(): boolean { + return !!this.config?.live.preload; + } + + protected render(): TemplateResult | void { + // Only essential items should be added to the below list, since we want the + // overall views pane to render in ~almost all cases (e.g. for a camera + // initialization error to display, `view` and `cameraConfig` may both be + // undefined, but we still want to render). + if (!this.hass || !this.config || !this.nonOverriddenConfig) { + return html``; + } + + // Render but hide the live view if there's a message, or if it's preload + // mode and the view is not live. + const liveClasses = { + hidden: this._shouldLivePreload() && !this.view?.is('live'), + }; + const overallClasses = { + hidden: !!this.hide, + }; + + const thumbnailConfig = this.view?.is('live') + ? this.config.live.controls.thumbnails + : this.view?.isViewerView() + ? this.config.media_viewer.controls.thumbnails + : this.view?.is('timeline') + ? this.config.timeline.controls.thumbnails + : undefined; + + const miniTimelineConfig = this.view?.is('live') + ? this.config.live.controls.timeline + : this.view?.isViewerView() + ? this.config.media_viewer.controls.timeline + : undefined; + + const cameraConfig = this.view + ? this.cameraManager?.getStore().getCameraConfig(this.view.camera) ?? null + : null; + + return html` + ${!this.hide && this.view?.is('image') && cameraConfig + ? html` + ` + : ``} + ${!this.hide && this.view?.isGalleryView() + ? html` + ` + : ``} + ${!this.hide && this.view?.isViewerView() + ? html` + + + ` + : ``} + ${!this.hide && 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. + + // Note: uses nonOverriddenConfig rather than the + // overriden config as it does it's own overriding as part of the camera + // carousel. + this._shouldLivePreload() || (!this.hide && this.view?.is('live')) + ? html` + + + ` + : `` + } + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(viewsStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-views': FrigateCardViews; + } +} diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index f95ce8ce..16aba0f9 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -1,22 +1,24 @@ -import { cloneDeep, get, isEqual, set } from 'lodash-es'; +import cloneDeep from 'lodash-es/cloneDeep'; +import get from 'lodash-es/get'; +import isEqual from 'lodash-es/isEqual'; +import set from 'lodash-es/set'; import { CONF_CAMERAS, - CONF_CAMERAS_ARRAY_CAMERA_ENTITY, - CONF_CAMERAS_ARRAY_LIVE_PROVIDER, - CONF_IMAGE_URL, + CONF_CAMERAS_GLOBAL_IMAGE, + CONF_CAMERAS_GLOBAL_JSMPEG, + CONF_CAMERAS_GLOBAL_WEBRTC_CARD, + CONF_ELEMENTS, 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_GALLERY, CONF_MEDIA_VIEWER, - CONF_MENU, CONF_MENU_BUTTONS_CAMERAS, + CONF_MENU_BUTTONS_CAMERA_UI, 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, @@ -25,22 +27,19 @@ import { CONF_MENU_POSITION, CONF_MENU_STYLE, CONF_OVERRIDES, - CONF_VIEW_DEFAULT, - CONF_VIEW_TIMEOUT_SECONDS, - CONF_VIEW_UPDATE_ENTITIES, } from './const'; import { BUTTON_SIZE_MIN, RawFrigateCardConfig, - RawFrigateCardConfigArray, THUMBNAIL_WIDTH_MAX, THUMBNAIL_WIDTH_MIN, } from './types'; +import { arrayify } from './utils/basic'; /** * Set a configuration value. * @param obj The configuration. - * @param key The key to the property to set. + * @param keys The key to the property to set. * @param value The value to set. */ @@ -55,7 +54,8 @@ export const setConfigValue = ( /** * Get a configuration value. * @param obj The configuration. - * @param key The key to the property to retrieve. + * @param keys The key to the property to retrieve. + * @param def Default if key not found. * @returns The property or undefined if not found. */ export const getConfigValue = ( @@ -94,7 +94,6 @@ export const upgradeConfig = function (obj: RawFrigateCardConfig): boolean { for (let i = 0; i < UPGRADES.length; i++) { upgraded = UPGRADES[i](obj) || upgraded; } - trimConfig(obj); return upgraded; }; @@ -104,30 +103,7 @@ export const upgradeConfig = function (obj: RawFrigateCardConfig): boolean { * @returns `true` if the configuration is upgradeable. */ export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean { - const newObj = JSON.parse(JSON.stringify(obj)); - return upgradeConfig(newObj); -}; - -/** - * Remove empty sections from a configuration. - * @param obj Configuration object. - * @returns `true` if the configuration was modified. - */ -export const trimConfig = function (obj: RawFrigateCardConfig): boolean { - const keys = Object.keys(obj); - let modified = false; - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - if (typeof obj[key] === 'object' && obj[key] != null) { - modified ||= trimConfig(obj[key] as RawFrigateCardConfig); - - if (!Object.keys(obj[key] as RawFrigateCardConfig).length) { - delete obj[key]; - modified = true; - } - } - } - return modified; + return upgradeConfig(copyConfig(obj)); }; /** @@ -135,28 +111,10 @@ export const trimConfig = function (obj: RawFrigateCardConfig): boolean { * @param obj Configuration to copy. * @returns A new deeply-copied configuration. */ -export const copyConfig = function (obj: RawFrigateCardConfig): RawFrigateCardConfig { +export const copyConfig = (obj: T): T => { return cloneDeep(obj); }; -/** - * Determines if a property is not an object. - * @param value The value. - * @returns `true` is the value is not an object. - */ -const isNotObject = function (value: unknown): unknown | undefined { - return typeof value !== 'object' ? value : undefined; -}; - -/** - * Converts to a number or return undefined. - * @param value The value. - * @returns A number or undefined. - */ -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. @@ -217,7 +175,7 @@ const deleteProperty = function (_value: unknown): number | null | undefined { * @param transform An optional transform for the value. * @returns `true` if the configuration was modified. */ -export const moveConfigValue = ( +const moveConfigValue = ( obj: RawFrigateCardConfig, oldPath: string, newPath: string, @@ -363,39 +321,6 @@ const upgradeArrayValue = function ( }; }; -/** - * Upgrade from a singular camera model to multiple. - * @returns An upgrade function. - */ -const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) => { - return function (obj: RawFrigateCardConfig): boolean { - let modified = false; - const cameras = getConfigValue(obj, CONF_CAMERAS) as RawFrigateCardConfigArray; - - // Only do an upgrade if the cameras section does not exist. - if (cameras !== undefined) { - return false; - } - - const imports = { - camera_entity: CONF_CAMERAS_ARRAY_CAMERA_ENTITY, - '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, - }; - Object.keys(imports).forEach((key) => { - modified = - moveConfigValue(obj, key, getArrayConfigPath(imports[key], 0)) || modified; - }); - return modified; - }; -}; - /** * Upgrade from a menu-mode to a style & position. * @returns An upgrade function. @@ -460,40 +385,6 @@ const upgradeMenuModeToStyleAndPosition = (): (( }; }; -/** - * Upgrade from a condition on the menu (to allow rendering) to a menu mode - * override instead. - * @param key A string key. - * @returns A safe key. - */ -const upgradeMenuConditionToMenuOverride = (): (( - obj: RawFrigateCardConfig, -) => boolean) => { - return function (obj: RawFrigateCardConfig): boolean { - const menuConditions = getConfigValue( - obj, - `${CONF_MENU}.conditions`, - ) as RawFrigateCardConfig; - - if (menuConditions === undefined) { - return false; - } - - const overrides = - (getConfigValue(obj, `${CONF_OVERRIDES}`) as RawFrigateCardConfigArray) || []; - setConfigValue(obj, `${CONF_OVERRIDES}.[${overrides.length}]`, { - conditions: menuConditions, - overrides: { - menu: { - mode: 'none', - }, - }, - }); - deleteConfigValue(obj, `${CONF_MENU}.conditions`); - return true; - }; -}; - /** * Transform a menu button from a boolean to a priority. * @param value The boolean true/false for show/hide the switch. @@ -544,48 +435,75 @@ const upgradeThumbnailShowControlsToIndividualControls = ( }; }; +/** + * Recursively upgrade an object. + * @param transform A transform applied to each object recursively. + * @param getObject A function to get the object to be upgraded. + * @returns An upgrade function. + */ +const recursiveUpgradeObject = ( + transform: (data: RawFrigateCardConfig) => boolean, + getObject?: (data: RawFrigateCardConfig) => RawFrigateCardConfig | undefined | null, +): ((data: RawFrigateCardConfig) => boolean) => { + const recurse = (data: RawFrigateCardConfig): boolean => { + let result = false; + if (data && typeof data === 'object') { + const object = getObject ? getObject(data) : data; + if (object) { + result = transform(object) || result; + } + if (Array.isArray(data)) { + data + .filter((item) => typeof item === 'object') + .forEach((item: RawFrigateCardConfig) => { + result = recurse(item) || result; + }); + } else { + Object.keys(data) + .filter((key) => typeof data[key] === 'object') + .forEach((key) => { + result = recurse(data[key] as RawFrigateCardConfig) || result; + }); + } + } + return result; + }; + return recurse; +}; + +/** + * Transform mediaLoaded -> media_loaded + * @param data Input data. + * @returns `true` if the configuration was modified. + */ +const transformConditionMediaLoaded = (data: unknown): boolean => { + if (typeof data === 'object' && data && data['mediaLoaded'] !== undefined) { + data['media_loaded'] = data['mediaLoaded']; + delete data['mediaLoaded']; + return true; + } + return false; +}; + +/** + * Transform action frigate_ui -> camera_ui + * @param data Input data. + * @returns `true` if the configuration was modified. + */ +const transformFrigateUIAction = (data: unknown): boolean => { + if ( + typeof data === 'object' && + data && + data['action'] === 'custom:frigate-card-action' && + data['frigate_card_action'] === 'frigate_ui' + ) { + data['frigate_card_action'] = 'camera_ui'; + return true; + } + return false; +}; + const UPGRADES = [ - // v1.2.1 -> v2.0.0 - upgradeMoveTo('frigate_url', 'frigate.url'), - upgradeMoveTo('frigate_client_id', 'frigate.client_id'), - upgradeMoveTo('frigate_camera_name', 'frigate.camera_name'), - upgradeMoveTo('label', 'frigate.label'), - upgradeMoveTo('zone', 'frigate.zone'), - upgradeMoveTo('view_default', CONF_VIEW_DEFAULT), - upgradeMoveTo('view_timeout', 'view.timeout'), - upgradeMoveTo('live_provider', 'live.provider'), - upgradeMoveTo('live_preload', CONF_LIVE_PRELOAD), - upgradeMoveTo('webrtc', 'live.webrtc'), - upgradeMoveTo('autoplay_clip', 'event_viewer.autoplay_clip'), - 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', { transform: isNotObject }), - - // v2.0.0 -> v2.1.0 - upgradeMoveTo('update_entities', CONF_VIEW_UPDATE_ENTITIES), - - // v2.1.0 -> v3.0.0-rc.1 - upgradeToMultipleCameras(), - upgradeMenuConditionToMenuOverride(), - 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, - 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, @@ -616,7 +534,7 @@ const UPGRADES = [ 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('menu.buttons.frigate_ui', menuButtonBooleanToObject), upgradeWithOverrides(CONF_MENU_BUTTONS_FULLSCREEN, menuButtonBooleanToObject), upgrade(CONF_LIVE_LAZY_UNLOAD, (val) => typeof val === 'boolean' ? (val ? 'all' : 'never') : undefined, @@ -642,4 +560,45 @@ const UPGRADES = [ upgradeThumbnailShowControlsToIndividualControls('media_viewer.controls.thumbnails'), upgradeThumbnailShowControlsToIndividualControls('live.controls.thumbnails'), upgradeThumbnailShowControlsToIndividualControls('timeline.controls.thumbnails'), + + // v4.0.0 -> v4.1.0 + upgradeArrayValue( + CONF_OVERRIDES, + transformConditionMediaLoaded, + (data) => data.conditions as RawFrigateCardConfig | undefined, + ), + (data: unknown): boolean => { + return recursiveUpgradeObject( + transformConditionMediaLoaded, + (data) => data.conditions as RawFrigateCardConfig | undefined, + )(typeof data === 'object' && data ? data[CONF_ELEMENTS] : {}); + }, + upgradeMoveToWithOverrides('event_gallery', CONF_MEDIA_GALLERY), + upgradeMoveToWithOverrides('menu.buttons.frigate_ui', CONF_MENU_BUTTONS_CAMERA_UI), + (data: unknown): boolean => { + return recursiveUpgradeObject(transformFrigateUIAction)( + typeof data === 'object' && data ? data : {}, + ); + }, + upgradeArrayValue( + CONF_CAMERAS, + upgradeWithOverrides('live_provider', (val) => + val === 'frigate-jsmpeg' ? 'jsmpeg' : val, + ), + ), + upgradeMoveToWithOverrides('live.image', CONF_CAMERAS_GLOBAL_IMAGE), + upgradeMoveToWithOverrides('live.jsmpeg', CONF_CAMERAS_GLOBAL_JSMPEG), + upgradeMoveToWithOverrides('live.webrtc_card', CONF_CAMERAS_GLOBAL_WEBRTC_CARD), + upgradeArrayValue( + CONF_CAMERAS, + upgradeMoveToWithOverrides('frigate.zone', 'frigate.zones', { + transform: (zone) => arrayify(zone), + }), + ), + upgradeArrayValue( + CONF_CAMERAS, + upgradeMoveToWithOverrides('frigate.label', 'frigate.labels', { + transform: (label) => arrayify(label), + }), + ), ]; diff --git a/src/const.ts b/src/const.ts index ca56f40e..9e79c0b7 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,4 +1,3 @@ -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; @@ -9,13 +8,31 @@ 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_LABELS = + `${CONF_CAMERAS}.#.frigate.labels` 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_FRIGATE_ZONES = + `${CONF_CAMERAS}.#.frigate.zones` as const; +export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const; +export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM = + `${CONF_CAMERAS}.#.go2rtc.stream` as const; +export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` as const; export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const; +export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const; +export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS = + `${CONF_CAMERAS}.#.image.refresh_seconds` as const; +export const CONF_CAMERAS_ARRAY_IMAGE_URL = `${CONF_CAMERAS}.#.image.url` as const; +export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN = + `${CONF_CAMERAS}.#.motioneye.images.directory_pattern` as const; +export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN = + `${CONF_CAMERAS}.#.motioneye.images.file_pattern` as const; +export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN = + `${CONF_CAMERAS}.#.motioneye.movies.directory_pattern` as const; +export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN = + `${CONF_CAMERAS}.#.motioneye.movies.file_pattern` as const; +export const CONF_CAMERAS_ARRAY_MOTIONEYE_URL = + `${CONF_CAMERAS}.#.motioneye.url` as const; +export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` 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 = @@ -33,14 +50,25 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY = export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES = `${CONF_CAMERAS}.#.triggers.entities` as const; -export const CONF_VIEW = 'view' as const; +const CONF_CAMERAS_GLOBAL = 'cameras_global' as const; +export const CONF_CAMERAS_GLOBAL_IMAGE = `${CONF_CAMERAS_GLOBAL}.image` as const; +export const CONF_CAMERAS_GLOBAL_JSMPEG = `${CONF_CAMERAS_GLOBAL}.jsmpeg` as const; +export const CONF_CAMERAS_GLOBAL_WEBRTC_CARD = + `${CONF_CAMERAS_GLOBAL}.webrtc_card` as const; +export const CONF_CAMERAS_GLOBAL_TRIGGERS_OCCUPANCY = + `${CONF_CAMERAS_GLOBAL}.triggers.occupancy` as const; +export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS = + `${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const; + +export const CONF_ELEMENTS = 'elements' as const; + +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; @@ -51,15 +79,19 @@ export const CONF_VIEW_SCAN_UNTRIGGER_RESET = 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_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_MEDIA_GALLERY = 'media_gallery' as const; +export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE = + `${CONF_MEDIA_GALLERY}.controls.filter.mode` as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_details` as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_download_control` as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.show_timeline_control` as const; +export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE = + `${CONF_MEDIA_GALLERY}.controls.thumbnails.size` 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; @@ -68,6 +100,8 @@ export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as c 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_SNAPSHOT_CLICK_PLAYS_CLIP = + `${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` 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 = @@ -78,12 +112,27 @@ 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_DOWNLOAD_CONTROL = + `${CONF_MEDIA_VIEWER}.controls.thumbnails.show_download_control` 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_TIMELINE_CLUSTERING_THRESHOLD = + `${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA = + `${CONF_MEDIA_VIEWER}.controls.timeline.media` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE = + `${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS = + `${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE = + `${CONF_MEDIA_VIEWER}.controls.timeline.style` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS = + `${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` 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 = @@ -94,7 +143,7 @@ export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_X = export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y = `${CONF_MEDIA_VIEWER}.layout.position.y` as const; -export const CONF_LIVE = 'live' as const; +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; @@ -111,10 +160,24 @@ 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_DOWNLOAD_CONTROL = + `${CONF_LIVE}.controls.thumbnails.show_download_control` 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_TIMELINE_CLUSTERING_THRESHOLD = + `${CONF_LIVE}.controls.timeline.clustering_threshold` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_MEDIA = + `${CONF_LIVE}.controls.timeline.media` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_MODE = + `${CONF_LIVE}.controls.timeline.mode` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS = + `${CONF_LIVE}.controls.timeline.show_recordings` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_STYLE = + `${CONF_LIVE}.controls.timeline.style` as const; +export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS = + `${CONF_LIVE}.controls.timeline.window_seconds` 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; @@ -122,16 +185,14 @@ 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; +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; @@ -139,24 +200,27 @@ 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; +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_STYLE = `${CONF_TIMELINE}.style` 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_DOWNLOAD_CONTROL = + `${CONF_TIMELINE}.controls.thumbnails.show_download_control` 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; +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; @@ -167,18 +231,36 @@ 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_CAMERA_UI = `${CONF_MENU}.buttons.camera_ui` 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_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const; +export const CONF_MENU_BUTTONS_MEDIA_PLAYER = + `${CONF_MENU}.buttons.media_player` as const; export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const; +export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU}.buttons.timeline` as const; -export const CONF_DIMENSIONS = 'dimensions' as const; +const CONF_DIMENSIONS = 'dimensions' as const; export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const; export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = `${CONF_DIMENSIONS}.aspect_ratio_mode` as const; +export const CONF_DIMENSIONS_MAX_HEIGHT = `${CONF_DIMENSIONS}.max_height` as const; +export const CONF_DIMENSIONS_MIN_HEIGHT = `${CONF_DIMENSIONS}.min_height` as const; export const CONF_OVERRIDES = 'overrides' as const; +const CONF_PERFORMANCE = 'performance' as const; +export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`; +export const CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE = `${CONF_PERFORMANCE}.features.media_chunk_size`; +export const CONF_PERFORMANCE_PROFILE = `${CONF_PERFORMANCE}.profile`; +export const CONF_PERFORMANCE_STYLE_BOX_SHADOW = `${CONF_PERFORMANCE}.style.box_shadow`; +export const CONF_PERFORMANCE_STYLE_BORDER_RADIUS = `${CONF_PERFORMANCE}.style.border_radius`; + // Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93 export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; + +// The number of media items to fetch at a time (for clips/snapshot views, and +// gallery chunks). Smaller values will cause more frequent smaller fetches, but +// improved rendering performance. +export const MEDIA_CHUNK_SIZE_DEFAULT = 50; +export const MEDIA_CHUNK_SIZE_MAX = 1000; diff --git a/src/declarations.d.ts b/src/declarations.d.ts index cd69265b..9fd8e18c 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -1,4 +1,5 @@ declare module '*.scss'; +declare module '*.svg'; declare module '*.jpg'; declare module 'view' { // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/src/editor.ts b/src/editor.ts index b5f92116..2e637c7d 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -12,30 +12,36 @@ import { 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_LABELS, CONF_CAMERAS_ARRAY_FRIGATE_URL, - CONF_CAMERAS_ARRAY_FRIGATE_ZONE, + CONF_CAMERAS_ARRAY_FRIGATE_ZONES, + CONF_CAMERAS_ARRAY_GO2RTC_MODES, + CONF_CAMERAS_ARRAY_GO2RTC_STREAM, + CONF_CAMERAS_ARRAY_HIDE, CONF_CAMERAS_ARRAY_ICON, CONF_CAMERAS_ARRAY_ID, + CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS, + CONF_CAMERAS_ARRAY_IMAGE_URL, CONF_CAMERAS_ARRAY_LIVE_PROVIDER, + CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN, + CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN, + CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN, + CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN, + CONF_CAMERAS_ARRAY_MOTIONEYE_URL, 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_CAMERAS, 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_DIMENSIONS_ASPECT_RATIO, CONF_IMAGE_LAYOUT_FIT, CONF_IMAGE_LAYOUT_POSITION_X, CONF_IMAGE_LAYOUT_POSITION_Y, @@ -51,9 +57,15 @@ import { CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, + CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, + CONF_LIVE_CONTROLS_TIMELINE_MEDIA, + CONF_LIVE_CONTROLS_TIMELINE_MODE, + CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, + CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, CONF_LIVE_CONTROLS_TITLE_MODE, CONF_LIVE_DRAGGABLE, @@ -65,6 +77,12 @@ import { CONF_LIVE_PRELOAD, CONF_LIVE_SHOW_IMAGE_DURING_LOAD, CONF_LIVE_TRANSITION_EFFECT, + CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE, CONF_MEDIA_VIEWER_AUTO_MUTE, CONF_MEDIA_VIEWER_AUTO_PAUSE, CONF_MEDIA_VIEWER_AUTO_PLAY, @@ -73,9 +91,15 @@ import { 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_DOWNLOAD_CONTROL, 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_TIMELINE_CLUSTERING_THRESHOLD, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, CONF_MEDIA_VIEWER_DRAGGABLE, @@ -83,15 +107,22 @@ import { CONF_MEDIA_VIEWER_LAYOUT_POSITION_X, CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y, CONF_MEDIA_VIEWER_LAZY_LOAD, + CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MENU_ALIGNMENT, - CONF_MENU_BUTTONS, CONF_MENU_BUTTON_SIZE, + CONF_MENU_BUTTONS, CONF_MENU_POSITION, CONF_MENU_STYLE, + CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, + CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, + CONF_PERFORMANCE_PROFILE, + CONF_PERFORMANCE_STYLE_BORDER_RADIUS, + CONF_PERFORMANCE_STYLE_BOX_SHADOW, CONF_TIMELINE_CLUSTERING_THRESHOLD, CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, @@ -101,46 +132,78 @@ import { 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_SCAN, CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_SECONDS, + MEDIA_CHUNK_SIZE_MAX, + CONF_DIMENSIONS_MAX_HEIGHT, + CONF_DIMENSIONS_MIN_HEIGHT, + CONF_TIMELINE_STYLE, + CONF_LIVE_CONTROLS_TIMELINE_STYLE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, } 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, + FrigateCardConfig, + frigateCardConfigDefaults, 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'; +import { arrayMove, prettifyTitle } from './utils/basic.js'; +import { getCameraID } from './utils/camera.js'; +import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js'; +import { + getEntitiesFromHASS, + getEntityTitle, + sideLoadHomeAssistantElements, +} from './utils/ha'; +import { setLowPerformanceProfile } from './performance.js'; +import { + MOTIONEYE_ICON_SVG_PATH, + MOTIONEYE_ICON_SVG_VIEWBOX, +} from './camera-manager/motioneye/icon.js'; const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; +const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc'; +const MENU_CAMERAS_IMAGE = 'cameras.image'; +const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye'; const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; -const MENU_CAMERAS_WEBRTC = 'cameras.webrtc'; -const MENU_EVENT_GALLERY_CONTROLS = 'event_gallery.controls'; +const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card'; +const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider'; +const MENU_CAMERAS_ENGINE = 'cameras.engine'; const MENU_IMAGE_LAYOUT = 'image.layout'; const MENU_LIVE_CONTROLS = 'live.controls'; +const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous'; +const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails'; +const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline'; +const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title'; const MENU_LIVE_LAYOUT = 'live.layout'; +const MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails'; +const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter'; const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls'; +const MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS = 'media_viewer.controls.next_previous'; +const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails'; +const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline'; +const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title'; const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout'; -const MENU_TIMELINE_CONTROLS = 'timeline.controls'; +const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; const MENU_OPTIONS = 'options'; +const MENU_PERFORMANCE_FEATURES = 'performance.features'; +const MENU_PERFORMANCE_STYLE = 'performance.style'; const MENU_VIEW_SCAN = 'scan'; interface EditorOptionsSet { @@ -183,16 +246,16 @@ const options: EditorOptions = { name: localize('editor.live'), secondary: localize('editor.live_secondary'), }, + media_gallery: { + icon: 'grid', + name: localize('editor.media_gallery'), + secondary: localize('editor.media_gallery_secondary'), + }, media_viewer: { icon: 'filmstrip', 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'), - }, image: { icon: 'image', name: localize('editor.image'), @@ -208,6 +271,11 @@ const options: EditorOptions = { name: localize('editor.dimensions'), secondary: localize('editor.dimensions_secondary'), }, + performance: { + icon: 'speedometer', + name: localize('editor.performance'), + secondary: localize('editor.performance_secondary'), + }, overrides: { icon: 'file-replace', name: localize('editor.overrides'), @@ -219,6 +287,8 @@ const options: EditorOptions = { export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass?: HomeAssistant; @state() protected _config?: RawFrigateCardConfig; + @state() protected _defaults = copyConfig(frigateCardConfigDefaults); + protected _initialized = false; protected _configUpgradeable = false; @@ -230,8 +300,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'live', label: localize('config.view.views.live') }, { value: 'clips', label: localize('config.view.views.clips') }, { value: 'snapshots', label: localize('config.view.views.snapshots') }, + { value: 'recordings', label: localize('config.view.views.recordings') }, { value: 'clip', label: localize('config.view.views.clip') }, { value: 'snapshot', label: localize('config.view.views.snapshot') }, + { value: 'recording', label: localize('config.view.views.recording') }, { value: 'image', label: localize('config.view.views.image') }, { value: 'timeline', label: localize('config.view.views.timeline') }, ]; @@ -241,12 +313,29 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'current', label: localize('config.view.views.current') }, ]; + protected _filterModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'none', + label: localize('config.common.controls.filter.modes.none'), + }, + { + value: 'left', + label: localize('config.common.controls.filter.modes.left'), + }, + { + value: 'right', + label: localize('config.common.controls.filter.modes.right'), + }, + ]; + protected _menuStyles: EditorSelectOption[] = [ { value: '', label: '' }, { 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: 'hover-card', label: localize('config.menu.styles.hover-card') }, { value: 'outside', label: localize('config.menu.styles.outside') }, ]; @@ -266,33 +355,24 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'bottom', label: localize('config.menu.alignments.bottom') }, ]; - protected _eventViewerNextPreviousControlStyles: EditorSelectOption[] = [ - { value: '', label: '' }, - { - value: 'thumbnails', - label: localize('config.media_viewer.controls.next_previous.styles.thumbnails'), - }, - { - value: 'chevrons', - label: localize('config.media_viewer.controls.next_previous.styles.chevrons'), - }, - { - value: 'none', - label: localize('config.media_viewer.controls.next_previous.styles.none'), - }, - ]; - - protected _liveNextPreviousControlStyles: EditorSelectOption[] = [ + protected _nextPreviousControlStyles: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'chevrons', - label: localize('config.live.controls.next_previous.styles.chevrons'), + label: localize('config.common.controls.next_previous.styles.chevrons'), }, { value: 'icons', - label: localize('config.live.controls.next_previous.styles.icons'), + label: localize('config.common.controls.next_previous.styles.icons'), + }, + { + value: 'none', + label: localize('config.common.controls.next_previous.styles.none'), + }, + { + value: 'thumbnails', + label: localize('config.common.controls.next_previous.styles.thumbnails'), }, - { value: 'none', label: localize('config.live.controls.next_previous.styles.none') }, ]; protected _aspectRatioModes: EditorSelectOption[] = [ @@ -312,53 +392,56 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: '', label: '' }, { value: 'none', - label: localize('config.media_viewer.controls.thumbnails.modes.none'), + label: localize('config.common.controls.thumbnails.modes.none'), }, { value: 'above', - label: localize('config.media_viewer.controls.thumbnails.modes.above'), + label: localize('config.common.controls.thumbnails.modes.above'), }, { value: 'below', - label: localize('config.media_viewer.controls.thumbnails.modes.below'), + label: localize('config.common.controls.thumbnails.modes.below'), }, { value: 'left', - label: localize('config.media_viewer.controls.thumbnails.modes.left'), + label: localize('config.common.controls.thumbnails.modes.left'), }, { value: 'right', - label: localize('config.media_viewer.controls.thumbnails.modes.right'), + label: localize('config.common.controls.thumbnails.modes.right'), }, ]; protected _thumbnailMedias: EditorSelectOption[] = [ { value: '', label: '' }, - { value: 'clips', label: localize('config.live.controls.thumbnails.medias.clips') }, + { + value: 'clips', + label: localize('config.common.controls.thumbnails.medias.clips'), + }, { value: 'snapshots', - label: localize('config.live.controls.thumbnails.medias.snapshots'), + label: localize('config.common.controls.thumbnails.medias.snapshots'), }, ]; protected _titleModes: EditorSelectOption[] = [ { value: '', label: '' }, - { value: 'none', label: localize('config.media_viewer.controls.title.modes.none') }, + { value: 'none', label: localize('config.common.controls.title.modes.none') }, { value: 'popup-top-left', - label: localize('config.media_viewer.controls.title.modes.popup-top-left'), + label: localize('config.common.controls.title.modes.popup-top-left'), }, { value: 'popup-top-right', - label: localize('config.media_viewer.controls.title.modes.popup-top-right'), + label: localize('config.common.controls.title.modes.popup-top-right'), }, { value: 'popup-bottom-left', - label: localize('config.media_viewer.controls.title.modes.popup-bottom-left'), + label: localize('config.common.controls.title.modes.popup-bottom-left'), }, { value: 'popup-bottom-right', - label: localize('config.media_viewer.controls.title.modes.popup-bottom-right'), + label: localize('config.common.controls.title.modes.popup-bottom-right'), }, ]; @@ -377,9 +460,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor 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') }, + { value: 'all', label: localize('config.common.timeline.medias.all') }, + { value: 'clips', label: localize('config.common.timeline.medias.clips') }, + { value: 'snapshots', label: localize('config.common.timeline.medias.snapshots') }, + ]; + + protected _timelineStyleTypes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'ribbon', label: localize('config.common.timeline.styles.ribbon') }, + { value: 'stack', label: localize('config.common.timeline.styles.stack') }, ]; protected _darkModes: EditorSelectOption[] = [ @@ -421,6 +510,27 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'fill', label: localize('config.common.layout.fits.fill') }, ]; + protected _miniTimelineModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'none', label: localize('config.common.controls.timeline.modes.none') }, + { value: 'above', label: localize('config.common.controls.timeline.modes.above') }, + { value: 'below', label: localize('config.common.controls.timeline.modes.below') }, + ]; + + protected _performanceProfiles: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'low', label: localize('config.performance.profiles.low') }, + { value: 'high', label: localize('config.performance.profiles.high') }, + ]; + + protected _go2rtcModes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'mse', label: localize('config.cameras.go2rtc.modes.mse') }, + { value: 'webrtc', label: localize('config.cameras.go2rtc.modes.webrtc') }, + { value: 'mp4', label: localize('config.cameras.go2rtc.modes.mp4') }, + { value: 'mjpeg', label: localize('config.cameras.go2rtc.modes.mjpeg') }, + ]; + 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 @@ -428,6 +538,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor // such, RawFrigateCardConfig is used as the type. this._config = config; this._configUpgradeable = isConfigUpgradeable(config); + + let unvalidatedProfile: string | null = null; + try { + // this._config may not be a valid FrigateCardConfig as it has not been + // parsed. Attempt to pull out the performance profile. + unvalidatedProfile = (this._config as FrigateCardConfig).performance?.profile; + } catch (_) {} + + if (unvalidatedProfile === 'high' || unvalidatedProfile === 'low') { + const defaults = copyConfig(frigateCardConfigDefaults); + if (unvalidatedProfile === 'low') { + setLowPerformanceProfile(this._config, defaults); + } + this._defaults = defaults; + } } /** @@ -448,7 +573,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor * @param optionSetName The name of the EditorOptionsSet. * @returns A rendered template. */ - protected _renderOptionSetHeader(optionSetName: string): TemplateResult { + protected _renderOptionSetHeader( + optionSetName: string, + titleClass?: string, + ): TemplateResult { const optionSet = options[optionSetName]; return html` @@ -460,7 +588,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor >
-
${optionSet.name}
+
${optionSet.name}
${optionSet.secondary}
@@ -517,7 +645,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor */ protected _renderOptionSelector( configPath: string, - options: string[] | { value: string; label: string }[], + options: string[] | { value: string; label: string }[] = [], params?: { multiple?: boolean; label?: string; @@ -531,7 +659,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderSwitch( CONF_VIEW_SCAN_ENABLED, - frigateCardConfigDefaults.view.scan.enabled, + this._defaults.view.scan.enabled, { label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`), }, )} ${this._renderSwitch( CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, - frigateCardConfigDefaults.view.scan.show_trigger_status, + this._defaults.view.scan.show_trigger_status, { label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`), }, )} ${this._renderSwitch( CONF_VIEW_SCAN_UNTRIGGER_RESET, - frigateCardConfigDefaults.view.scan.untrigger_reset, + this._defaults.view.scan.untrigger_reset, )} ${this._renderNumberInput(CONF_VIEW_SCAN_UNTRIGGER_SECONDS, { - default: frigateCardConfigDefaults.view.scan.untrigger_seconds, + default: this._defaults.view.scan.untrigger_seconds, })}
` : ''} @@ -713,7 +867,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ? html`
${this._renderSwitch( `${CONF_MENU_BUTTONS}.${button}.enabled`, - frigateCardConfigDefaults.menu.buttons[button]?.enabled ?? true, + this._defaults.menu.buttons[button]?.enabled ?? true, { label: localize('config.menu.buttons.enabled'), }, @@ -727,7 +881,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor )} ${this._renderNumberInput(`${CONF_MENU_BUTTONS}.${button}.priority`, { max: FRIGATE_MENU_PRIORITY_MAX, - default: frigateCardConfigDefaults.menu.buttons[button]?.priority, + default: this._defaults.menu.buttons[button]?.priority, label: localize('config.menu.buttons.priority'), })} ${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, { @@ -755,6 +909,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor icon: { name?: string; path?: string; + viewBox?: string; }, template: TemplateResult, ): TemplateResult { @@ -774,7 +929,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${icon.name ? html` ` : icon.path - ? html` ` + ? html` + + ` : ``} ${localize(labelPath)}
@@ -784,7 +941,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor /** * Render a media layout section. - * @param domain The submenu domain. + * @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. @@ -819,6 +976,260 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ); } + /** + * Render the core timeline controls (mini or full timeline), + * @param configPathStyle Timeline style config path. + * @param configPathWindowSeconds Timeline window config path. + * @param configPathClusteringThreshold Clustering threshold config path. + * @param configPathTimelineMedia Timeline media config path. + * @param configPathShowRecordings Show recordings config path. + * @param defaultShowRecordings Default value of show_recordings. + * @returns A rendered template. + */ + protected _renderTimelineCoreControls( + configPathStyle: string, + configPathWindowSeconds: string, + configPathClusteringThreshold: string, + configPathTimelineMedia: string, + configPathShowRecordings: string, + defaultShowRecordings: boolean, + ): TemplateResult { + return html` + ${this._renderOptionSelector(configPathStyle, this._timelineStyleTypes, { + label: localize(`config.common.${CONF_TIMELINE_STYLE}`), + })} + ${this._renderNumberInput(configPathWindowSeconds, { + label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`), + })} + ${this._renderNumberInput(configPathClusteringThreshold, { + label: localize(`config.common.${CONF_TIMELINE_CLUSTERING_THRESHOLD}`), + })} + ${this._renderOptionSelector(configPathTimelineMedia, this._timelineMediaTypes, { + label: localize(`config.common.${CONF_TIMELINE_MEDIA}`), + })} + ${this._renderSwitch(configPathShowRecordings, defaultShowRecordings, { + label: localize(`config.common.${CONF_TIMELINE_SHOW_RECORDINGS}`), + })}`; + } + + /** + * Render the mini timeline controls. + * @param domain The submenu domain. + * @param configPathWindowSeconds Timeline window config path. + * @param configPathClusteringThreshold Clustering threshold config path. + * @param configPathTimelineMedia Timeline media config path. + * @param configPathShowRecordings Show recordings config path. + * @returns A rendered template. + */ + protected _renderMiniTimeline( + domain: string, + configPathMode: string, + configPathStyle: string, + configPathWindowSeconds: string, + configPathClusteringThreshold: string, + configPathTimelineMedia: string, + configPathShowRecordings: string, + showRecordingsDefault: boolean, + ): TemplateResult | void { + return this._putInSubmenu( + domain, + true, + 'config.common.controls.timeline.editor_label', + { name: 'mdi:chart-gantt' }, + html` ${this._renderOptionSelector(configPathMode, this._miniTimelineModes, { + label: localize('config.common.controls.timeline.mode'), + })} + ${this._renderTimelineCoreControls( + configPathStyle, + configPathWindowSeconds, + configPathClusteringThreshold, + configPathTimelineMedia, + configPathShowRecordings, + showRecordingsDefault, + )}`, + ); + } + + /** + * Render the next & previous controls. + * @param domain The submenu domain. + * @param configPathStyle Next previous style config path. + * @param configPathSize Next previous size config path. + * @returns A rendered template. + */ + protected _renderNextPreviousControls( + domain: string, + configPathStyle: string, + configPathSize: string, + options?: { + allowIcons?: boolean; + allowThumbnails?: boolean; + }, + ): TemplateResult | void { + return this._putInSubmenu( + domain, + true, + 'config.common.controls.next_previous.editor_label', + { name: 'mdi:arrow-right-bold-circle' }, + html` + ${this._renderOptionSelector( + configPathStyle, + this._nextPreviousControlStyles.filter( + (item) => + (!!options?.allowThumbnails || item.value !== 'thumbnails') && + (!!options?.allowIcons || item.value !== 'icons'), + ), + { + label: localize('config.common.controls.next_previous.style'), + }, + )} + ${this._renderNumberInput(configPathSize, { + min: BUTTON_SIZE_MIN, + label: localize('config.common.controls.next_previous.size'), + })} + `, + ); + } + + /** + * Render the thumbnails controls. + * @param domain The submenu domain. + * @param configPathMode Thumbnails mode config path. + * @param configPathSize Thumbnails size config path. + * @param configPathShowDetails Thumbnails show details config path. + * @param configPathShowFavoriteControl Thumbnails show favorite control config path. + * @param configPathShowTimelineControl Thumbnails show timeline control config path, + * @param options An optional config path to media selection and mini-timeline mode. + * @returns A rendered template. + */ + protected _renderThumbnailsControls( + domain: string, + configPathSize: string, + configPathShowDetails: string, + configPathShowFavoriteControl: string, + configPathShowTimelineControl: string, + configPathShowDownloadControl: string, + defaults: { + show_details: boolean; + show_favorite_control: boolean; + show_timeline_control: boolean; + show_download_control: boolean; + }, + options?: { + configPathMedia?: string; + configPathMode?: string; + }, + ): TemplateResult | void { + return this._putInSubmenu( + domain, + true, + 'config.common.controls.thumbnails.editor_label', + { name: 'mdi:image-text' }, + html` + ${options?.configPathMode + ? html`${this._renderOptionSelector( + options.configPathMode, + this._thumbnailModes, + { + label: localize('config.common.controls.thumbnails.mode'), + }, + )}` + : html``} + ${options?.configPathMedia + ? html`${this._renderOptionSelector( + options.configPathMedia, + this._thumbnailMedias, + { + label: localize('config.common.controls.thumbnails.media'), + }, + )}` + : html``} + ${this._renderNumberInput(configPathSize, { + min: THUMBNAIL_WIDTH_MIN, + max: THUMBNAIL_WIDTH_MAX, + label: localize('config.common.controls.thumbnails.size'), + })} + ${this._renderSwitch(configPathShowDetails, defaults.show_details, { + label: localize('config.common.controls.thumbnails.show_details'), + })} + ${this._renderSwitch( + configPathShowFavoriteControl, + defaults.show_favorite_control, + { + label: localize('config.common.controls.thumbnails.show_favorite_control'), + }, + )} + ${this._renderSwitch( + configPathShowTimelineControl, + defaults.show_timeline_control, + { + label: localize('config.common.controls.thumbnails.show_timeline_control'), + }, + )} + ${this._renderSwitch( + configPathShowDownloadControl, + defaults.show_download_control, + { + label: localize('config.common.controls.thumbnails.show_download_control'), + }, + )} + `, + ); + } + + /** + * Render the thumbnails controls. + * @param domain The submenu domain. + * @param configPathMode Filter mode config path. + * @returns A rendered template. + */ + protected _renderFilterControls( + domain: string, + configPathMode: string, + ): TemplateResult | void { + return this._putInSubmenu( + domain, + true, + 'config.common.controls.filter.editor_label', + { name: 'mdi:filter-cog' }, + html` + ${configPathMode + ? html`${this._renderOptionSelector(configPathMode, this._filterModes, { + label: localize('config.common.controls.filter.mode'), + })}` + : html``} + `, + ); + } + + /** + * Render the titles controls. + * @param domain The submenu domain. + * @param configPathMode Title mode config path. + * @param configPathDurationSeconds Title duration seconds config path. + * @returns A rendered template. + */ + protected _renderTitleControls( + menuDomain: string, + configPathMode: string, + configPathDurationSeconds: string, + ): TemplateResult | void { + return this._putInSubmenu( + menuDomain, + true, + 'config.common.controls.title.editor_label', + { name: 'mdi:subtitles' }, + html` ${this._renderOptionSelector(configPathMode, this._titleModes, { + label: localize('config.common.controls.title.mode'), + })} + ${this._renderNumberInput(configPathDurationSeconds, { + min: 0, + max: 60, + label: localize('config.common.controls.title.duration_seconds'), + })}`, + ); + } + /** * Render a camera section. * @param cameras The full array of cameras. @@ -837,8 +1248,16 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'auto', label: localize('config.cameras.live_providers.auto') }, { value: 'ha', label: localize('config.cameras.live_providers.ha') }, { - value: 'frigate-jsmpeg', - label: localize('config.cameras.live_providers.frigate-jsmpeg'), + value: 'image', + label: localize('config.cameras.live_providers.image'), + }, + { + value: 'jsmpeg', + label: localize('config.cameras.live_providers.jsmpeg'), + }, + { + value: 'go2rtc', + label: localize('config.cameras.live_providers.go2rtc'), }, { value: 'webrtc-card', @@ -977,46 +1396,157 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderStringInput( getArrayConfigPath(CONF_CAMERAS_ARRAY_ID, cameraIndex), )} + ${this._renderSwitch( + getArrayConfigPath(CONF_CAMERAS_ARRAY_HIDE, cameraIndex), + this._defaults.cameras.hide, + )} ${this._putInSubmenu( - MENU_CAMERAS_FRIGATE, - cameraIndex, - 'config.cameras.frigate.options', - { path: FRIGATE_ICON_SVG_PATH }, - html` + MENU_CAMERAS_ENGINE, + true, + 'config.cameras.engines.editor_label', + { name: 'mdi:engine' }, + html`${this._putInSubmenu( + MENU_CAMERAS_FRIGATE, + cameraIndex, + 'config.cameras.frigate.editor_label', + { 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._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_FRIGATE_LABELS, cameraIndex), + [], + { + multiple: true, + label: localize('config.cameras.frigate.labels'), + }, + )} + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_FRIGATE_ZONES, cameraIndex), + [], + { + multiple: true, + label: localize('config.cameras.frigate.zones'), + }, + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID, + cameraIndex, + ), + )} + `, + )} + ${this._putInSubmenu( + MENU_CAMERAS_MOTIONEYE, + cameraIndex, + 'config.cameras.motioneye.editor_label', + { path: MOTIONEYE_ICON_SVG_PATH, viewBox: MOTIONEYE_ICON_SVG_VIEWBOX }, + html` + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_MOTIONEYE_URL, cameraIndex), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN, + cameraIndex, + ), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN, + cameraIndex, + ), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN, + cameraIndex, + ), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN, + cameraIndex, + ), + )} + `, + )} `, + )} + ${this._putInSubmenu( + MENU_CAMERAS_LIVE_PROVIDER, + true, + 'config.cameras.live_provider_options.editor_label', + { name: 'mdi:cctv' }, + html` ${this._putInSubmenu( + MENU_CAMERAS_GO2RTC, + cameraIndex, + 'config.cameras.go2rtc.editor_label', + { name: 'mdi:alpha-g-circle' }, + html`${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_GO2RTC_MODES, cameraIndex), + this._go2rtcModes, + { + multiple: true, + label: localize('config.cameras.go2rtc.modes.editor_label'), + }, + )} ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_GO2RTC_STREAM, cameraIndex), + )}`, + )} + ${this._putInSubmenu( + MENU_CAMERAS_IMAGE, + true, + 'config.cameras.image.editor_label', + { name: 'mdi:image' }, + html` + ${this._renderNumberInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS, + cameraIndex, + ), + )} + ${this._renderStringInput( + getArrayConfigPath(CONF_CAMERAS_ARRAY_IMAGE_URL, cameraIndex), + )} + `, + )} + ${this._putInSubmenu( + MENU_CAMERAS_WEBRTC_CARD, + cameraIndex, + 'config.cameras.webrtc_card.editor_label', + { name: 'mdi:webrtc' }, + html`${this._renderEntitySelector( getArrayConfigPath( - CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME, + CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, cameraIndex, ), + 'camera', )} ${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, - ), - )} - `, + getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, cameraIndex), + )}`, + )}`, )} ${this._putInSubmenu( MENU_CAMERAS_DEPENDENCIES, cameraIndex, - 'config.cameras.dependencies.options', + 'config.cameras.dependencies.editor_label', { name: 'mdi:graph' }, html` ${this._renderSwitch( getArrayConfigPath( CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS, cameraIndex, ), - frigateCardConfigDefaults.cameras.dependencies.all_cameras, + this._defaults.cameras.dependencies.all_cameras, )} ${this._renderOptionSelector( getArrayConfigPath( @@ -1032,15 +1562,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._putInSubmenu( MENU_CAMERAS_TRIGGERS, cameraIndex, - 'config.cameras.triggers.options', + 'config.cameras.triggers.editor_label', { name: 'mdi:magnify-scan' }, html` ${this._renderSwitch( getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, cameraIndex), - frigateCardConfigDefaults.cameras.triggers.occupancy, + this._defaults.cameras.triggers.occupancy, )} ${this._renderSwitch( getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_MOTION, cameraIndex), - frigateCardConfigDefaults.cameras.triggers.motion, + this._defaults.cameras.triggers.motion, )} ${this._renderOptionSelector( getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES, cameraIndex), @@ -1050,19 +1580,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, )}`, )} - ${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), - )}`, - )}
` : ``}
@@ -1077,20 +1594,23 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor */ protected _renderStringInput( configPath: string, - type?: - | 'number' - | 'text' - | 'search' - | 'tel' - | 'url' - | 'email' - | 'password' - | 'date' - | 'month' - | 'week' - | 'time' - | 'datetime-local' - | 'color', + params?: { + label?: string; + type?: + | 'number' + | 'text' + | 'search' + | 'tel' + | 'url' + | 'email' + | 'password' + | 'date' + | 'month' + | 'week' + | 'time' + | 'datetime-local' + | 'color'; + }, ): TemplateResult | void { if (!this._config) { return; @@ -1099,8 +1619,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return html` this._valueChangedHandler(configPath, ev)} @@ -1150,7 +1670,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return html``; } - const defaults = frigateCardConfigDefaults; const entities = getEntitiesFromHASS(this.hass); const cameras = (getConfigValue(this._config, CONF_CAMERAS) || []) as RawFrigateCardConfigArray; @@ -1180,8 +1699,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSetHeader('cameras')} ${this._expandedMenus[MENU_OPTIONS] === 'cameras' ? html` - ${cameras.map((_, index) => this._renderCamera(cameras, index, entities))} - ${this._renderCamera(cameras, cameras.length, entities, true)} +
+ ${cameras.map((_, index) => + this._renderCamera(cameras, index, entities), + )} + ${this._renderCamera(cameras, cameras.length, entities, true)} +
` : ''} ${this._renderOptionSetHeader('view')} @@ -1196,10 +1719,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)} ${this._renderNumberInput(CONF_VIEW_TIMEOUT_SECONDS)} ${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)} - ${this._renderSwitch(CONF_VIEW_UPDATE_FORCE, defaults.view.update_force)} + ${this._renderSwitch( + CONF_VIEW_UPDATE_FORCE, + this._defaults.view.update_force, + )} ${this._renderSwitch( CONF_VIEW_UPDATE_CYCLE_CAMERA, - defaults.view.update_cycle_camera, + this._defaults.view.update_cycle_camera, )} ${this._renderViewScanMenu()}
@@ -1215,27 +1741,30 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, { min: BUTTON_SIZE_MIN, })} + ${this._renderMenuButton('frigate') /* */} + ${this._renderMenuButton('cameras') /* */} + ${this._renderMenuButton('substreams') /* */} + ${this._renderMenuButton('live') /* */} + ${this._renderMenuButton('clips') /* */} + ${this._renderMenuButton('snapshots')} + ${this._renderMenuButton('recordings')} + ${this._renderMenuButton('image') /* */} + ${this._renderMenuButton('download')} + ${this._renderMenuButton('camera_ui')} + ${this._renderMenuButton('fullscreen')} + ${this._renderMenuButton('expand') /* */} + ${this._renderMenuButton('timeline')} + ${this._renderMenuButton('media_player')} - ${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')} ${this._expandedMenus[MENU_OPTIONS] === 'live' ? html`
- ${this._renderSwitch(CONF_LIVE_PRELOAD, defaults.live.preload)} - ${this._renderSwitch(CONF_LIVE_DRAGGABLE, defaults.live.draggable)} - ${this._renderSwitch(CONF_LIVE_LAZY_LOAD, defaults.live.lazy_load)} + ${this._renderSwitch(CONF_LIVE_PRELOAD, this._defaults.live.preload)} + ${this._renderSwitch(CONF_LIVE_DRAGGABLE, this._defaults.live.draggable)} + ${this._renderSwitch(CONF_LIVE_LAZY_LOAD, this._defaults.live.lazy_load)} ${this._renderOptionSelector( CONF_LIVE_LAZY_UNLOAD, this._mediaActionNegativeConditions, @@ -1262,56 +1791,50 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor )} ${this._renderSwitch( CONF_LIVE_SHOW_IMAGE_DURING_LOAD, - defaults.live.show_image_during_load, + this._defaults.live.show_image_during_load, )} ${this._putInSubmenu( MENU_LIVE_CONTROLS, true, - 'config.live.controls.options', + 'config.live.controls.editor_label', { name: 'mdi:gamepad' }, html` - ${this._renderOptionSelector( + ${this._renderNextPreviousControls( + MENU_LIVE_CONTROLS_NEXT_PREVIOUS, 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, + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, { - min: 0, - max: 60, + allowIcons: true, }, )} + ${this._renderThumbnailsControls( + MENU_LIVE_CONTROLS_THUMBNAILS, + CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + this._defaults.live.controls.thumbnails, + { + configPathMedia: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, + configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE, + }, + )} + ${this._renderTitleControls( + MENU_LIVE_CONTROLS_TITLE, + CONF_LIVE_CONTROLS_TITLE_MODE, + CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, + )} + ${this._renderMiniTimeline( + MENU_LIVE_CONTROLS_TIMELINE, + CONF_LIVE_CONTROLS_TIMELINE_MODE, + CONF_LIVE_CONTROLS_TIMELINE_STYLE, + CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, + CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, + CONF_LIVE_CONTROLS_TIMELINE_MEDIA, + CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, + this._defaults.live.controls.timeline.show_recordings, + )} `, )} ${this._renderMediaLayout( @@ -1324,33 +1847,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
` : ''} - ${this._renderOptionSetHeader('event_gallery')} - ${this._expandedMenus[MENU_OPTIONS] === 'event_gallery' + ${this._renderOptionSetHeader('media_gallery')} + ${this._expandedMenus[MENU_OPTIONS] === 'media_gallery' ? html`
- ${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._renderThumbnailsControls( + MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + this._defaults.media_gallery.controls.thumbnails, + )} + ${this._renderFilterControls( + MENU_MEDIA_GALLERY_CONTROLS_FILTER, + CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE, )}
` : ''} @@ -1375,59 +1886,60 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor )} ${this._renderSwitch( CONF_MEDIA_VIEWER_DRAGGABLE, - defaults.media_viewer.draggable, + this._defaults.media_viewer.draggable, )} ${this._renderSwitch( CONF_MEDIA_VIEWER_LAZY_LOAD, - defaults.media_viewer.lazy_load, + this._defaults.media_viewer.lazy_load, )} ${this._renderOptionSelector( CONF_MEDIA_VIEWER_TRANSITION_EFFECT, this._transitionEffects, )} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, + this._defaults.media_viewer.snapshot_click_plays_clip, + )} ${this._putInSubmenu( MENU_MEDIA_VIEWER_CONTROLS, true, - 'config.media_viewer.controls.options', + 'config.media_viewer.controls.editor_label', { name: 'mdi:gamepad' }, html` - ${this._renderOptionSelector( + ${this._renderNextPreviousControls( + MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, - this._eventViewerNextPreviousControlStyles, - )} - ${this._renderNumberInput( CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, { - min: BUTTON_SIZE_MIN, + allowThumbnails: true, }, )} - ${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( + ${this._renderThumbnailsControls( + MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE, 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, + CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + this._defaults.media_viewer.controls.thumbnails, + { + configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, + }, )} - ${this._renderOptionSelector( + ${this._renderTitleControls( + MENU_MEDIA_VIEWER_CONTROLS_TITLE, CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, - this._titleModes, - )} - ${this._renderNumberInput( CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, - { min: 0, max: 60 }, + )} + ${this._renderMiniTimeline( + MENU_MEDIA_VIEWER_CONTROLS_TIMELINE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA, + CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, + this._defaults.media_viewer.controls.timeline.show_recordings, )} `, )} @@ -1458,41 +1970,25 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSetHeader('timeline')} ${this._expandedMenus[MENU_OPTIONS] === 'timeline' ? html`
- ${this._renderNumberInput(CONF_TIMELINE_WINDOW_SECONDS)} - ${this._renderNumberInput(CONF_TIMELINE_CLUSTERING_THRESHOLD)} - ${this._renderOptionSelector( + ${this._renderTimelineCoreControls( + CONF_TIMELINE_STYLE, + CONF_TIMELINE_WINDOW_SECONDS, + CONF_TIMELINE_CLUSTERING_THRESHOLD, CONF_TIMELINE_MEDIA, - this._timelineMediaTypes, - )} - ${this._renderSwitch( CONF_TIMELINE_SHOW_RECORDINGS, - defaults.timeline.show_recordings, + this._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._renderThumbnailsControls( + MENU_TIMELINE_CONTROLS_THUMBNAILS, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL, + CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL, + this._defaults.timeline.controls.thumbnails, + { + configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, + }, )}
` : ''} @@ -1504,6 +2000,56 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor this._aspectRatioModes, )} ${this._renderStringInput(CONF_DIMENSIONS_ASPECT_RATIO)} + ${this._renderStringInput(CONF_DIMENSIONS_MAX_HEIGHT)} + ${this._renderStringInput(CONF_DIMENSIONS_MIN_HEIGHT)} + ` + : ''} + ${this._renderOptionSetHeader( + 'performance', + getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low' + ? 'warning' + : undefined, + )} + ${this._expandedMenus[MENU_OPTIONS] === 'performance' + ? html`
+ ${getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low' + ? this._renderInfo(localize('config.performance.warning')) + : html``} + ${this._renderOptionSelector( + CONF_PERFORMANCE_PROFILE, + this._performanceProfiles, + )} + ${this._putInSubmenu( + MENU_PERFORMANCE_FEATURES, + true, + 'config.performance.features.editor_label', + { name: 'mdi:feature-search' }, + html` + ${this._renderSwitch( + CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, + this._defaults.performance.features.animated_progress_indicator, + )} + ${this._renderNumberInput(CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, { + max: MEDIA_CHUNK_SIZE_MAX, + })} + `, + )} + ${this._putInSubmenu( + MENU_PERFORMANCE_STYLE, + true, + 'config.performance.style.editor_label', + { name: 'mdi:palette-swatch-variant' }, + html` + ${this._renderSwitch( + CONF_PERFORMANCE_STYLE_BORDER_RADIUS, + this._defaults.performance.style.border_radius, + )} + ${this._renderSwitch( + CONF_PERFORMANCE_STYLE_BOX_SHADOW, + this._defaults.performance.style.box_shadow, + )} + `, + )}
` : ''} ${this._config['overrides'] !== undefined diff --git a/src/external/go2rtc/README.md b/src/external/go2rtc/README.md new file mode 100644 index 00000000..1fc4c6bb --- /dev/null +++ b/src/external/go2rtc/README.md @@ -0,0 +1,9 @@ +# go2rtc Player + +**Link**: https://github.com/AlexxIT/go2rtc/tree/master/www + +**Description**: A video player imported from go2rtc. + +**Copyright**: [Alexey Khit](https://github.com/AlexxIT) + +**License**: [MIT](https://github.com/AlexxIT/go2rtc/blob/master/LICENSE) diff --git a/src/external/go2rtc/video-rtc.d.ts b/src/external/go2rtc/video-rtc.d.ts new file mode 100644 index 00000000..3a0af296 --- /dev/null +++ b/src/external/go2rtc/video-rtc.d.ts @@ -0,0 +1,22 @@ +export class VideoRTC extends HTMLElement { + DISCONNECT_TIMEOUT: number; + RECONNECT_TIMEOUT: number; + CODECS: string[]; + mode: string; + background: boolean; + visibilityThreshold: number; + visibilityCheck: boolean; + pcConfig: RTCConfiguration; + wsState: number; + pcState: number; + video: HTMLVideoElement; + ws: WebSocket | null; + wsURL: string; + pc: RTCPeerConnection; + connectTS: number; + mseCodecs: string; + + src: string | URL; + + oninit(): void; +} diff --git a/src/external/go2rtc/video-rtc.js b/src/external/go2rtc/video-rtc.js new file mode 100644 index 00000000..445aa94d --- /dev/null +++ b/src/external/go2rtc/video-rtc.js @@ -0,0 +1,597 @@ +/** + * Video player for go2rtc streaming application. + * + * All modern web technologies are supported in almost any browser except Apple Safari. + * + * Support: + * - RTCPeerConnection for Safari iOS 11.0+ + * - IntersectionObserver for Safari iOS 12.2+ + * + * Doesn't support: + * - MediaSource for Safari iOS all + * - Customized built-in elements (extends HTMLVideoElement) because all Safari + * - Public class fields because old Safari (before 14.0) + * - Autoplay for Safari + */ +export class VideoRTC extends HTMLElement { + constructor() { + super(); + + this.DISCONNECT_TIMEOUT = 5000; + this.RECONNECT_TIMEOUT = 30000; + + this.CODECS = [ + "avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen) + "avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen) + "avc1.640033", // H.264 high 5.1 (Chromecast with Google TV) + "hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra) + "mp4a.40.2", // AAC LC + "mp4a.40.5", // AAC HE + "opus", // OPUS Chrome + ]; + + /** + * [config] Supported modes (webrtc, mse, mp4, mjpeg). + * @type {string} + */ + this.mode = "webrtc,mse,mp4,mjpeg"; + + /** + * [config] Run stream when not displayed on the screen. Default `false`. + * @type {boolean} + */ + this.background = false; + + /** + * [config] Run stream only when player in the viewport. Stop when user scroll out player. + * Value is percentage of visibility from `0` (not visible) to `1` (full visible). + * Default `0` - disable; + * @type {number} + */ + this.visibilityThreshold = 0; + + /** + * [config] Run stream only when browser page on the screen. Stop when user change browser + * tab or minimise browser windows. + * @type {boolean} + */ + this.visibilityCheck = true; + + /** + * [config] WebRTC configuration + * @type {RTCConfiguration} + */ + this.pcConfig = { + iceServers: [{urls: 'stun:stun.l.google.com:19302'}], + sdpSemantics: 'unified-plan', // important for Chromecast 1 + }; + + /** + * [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED + * @type {number} + */ + this.wsState = WebSocket.CLOSED; + + /** + * [info] WebRTC connection state. + * @type {number} + */ + this.pcState = WebSocket.CLOSED; + + /** + * @type {HTMLVideoElement} + */ + this.video = null; + + /** + * @type {WebSocket} + */ + this.ws = null; + + /** + * @type {string|URL} + */ + this.wsURL = ""; + + /** + * @type {RTCPeerConnection} + */ + this.pc = null; + + /** + * @type {number} + */ + this.connectTS = 0; + + /** + * @type {string} + */ + this.mseCodecs = ""; + + /** + * [internal] Disconnect TimeoutID. + * @type {number} + */ + this.disconnectTID = 0; + + /** + * [internal] Reconnect TimeoutID. + * @type {number} + */ + this.reconnectTID = 0; + + /** + * [internal] Handler for receiving Binary from WebSocket. + * @type {Function} + */ + this.ondata = null; + + /** + * [internal] Handlers list for receiving JSON from WebSocket + * @type {Object.}} + */ + this.onmessage = null; + } + + /** + * Set video source (WebSocket URL). Support relative path. + * @param {string|URL} value + */ + set src(value) { + if (typeof value !== "string") value = value.toString(); + if (value.startsWith("http")) { + value = "ws" + value.substring(4); + } else if (value.startsWith("/")) { + value = "ws" + location.origin.substring(4) + value; + } + + this.wsURL = value; + + this.onconnect(); + } + + /** + * Play video. Support automute when autoplay blocked. + * https://developer.chrome.com/blog/autoplay/ + */ + play() { + this.video.play().catch(er => { + if (er.name === "NotAllowedError" && !this.video.muted) { + this.video.muted = true; + this.video.play().catch(() => console.debug); + } + }); + } + + /** + * Send message to server via WebSocket + * @param {Object} value + */ + send(value) { + if (this.ws) this.ws.send(JSON.stringify(value)); + } + + codecs(type) { + const test = type === "mse" + ? codec => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`) + : codec => this.video.canPlayType(`video/mp4; codecs="${codec}"`); + return this.CODECS.filter(test).join(); + } + + /** + * `CustomElement`. Invoked each time the custom element is appended into a + * document-connected element. + */ + connectedCallback() { + if (this.disconnectTID) { + clearTimeout(this.disconnectTID); + this.disconnectTID = 0; + } + + // because video autopause on disconnected from DOM + if (this.video) { + const seek = this.video.seekable; + if (seek.length > 0) { + this.video.currentTime = seek.end(seek.length - 1); + } + this.play(); + } else { + this.oninit(); + } + + this.onconnect(); + } + + /** + * `CustomElement`. Invoked each time the custom element is disconnected from the + * document's DOM. + */ + disconnectedCallback() { + if (this.background || this.disconnectTID) return; + if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return; + + this.disconnectTID = setTimeout(() => { + if (this.reconnectTID) { + clearTimeout(this.reconnectTID); + this.reconnectTID = 0; + } + + this.disconnectTID = 0; + + this.ondisconnect(); + }, this.DISCONNECT_TIMEOUT); + } + + /** + * Creates child DOM elements. Called automatically once on `connectedCallback`. + */ + oninit() { + this.video = document.createElement("video"); + this.video.controls = true; + this.video.playsInline = true; + this.video.preload = "auto"; + + this.video.style.display = "block"; // fix bottom margin 4px + this.video.style.width = "100%"; + this.video.style.height = "100%" + + this.appendChild(this.video); + + if (this.background) return; + + if ("hidden" in document && this.visibilityCheck) { + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }) + } + + if ("IntersectionObserver" in window && this.visibilityThreshold) { + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (!entry.isIntersecting) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }); + }, {threshold: this.visibilityThreshold}); + observer.observe(this); + } + } + + /** + * Connect to WebSocket. Called automatically on `connectedCallback`. + * @return {boolean} true if the connection has started. + */ + onconnect() { + if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false; + + // CLOSED or CONNECTING => CONNECTING + this.wsState = WebSocket.CONNECTING; + + this.connectTS = Date.now(); + + this.ws = new WebSocket(this.wsURL); + this.ws.binaryType = "arraybuffer"; + this.ws.addEventListener("open", ev => this.onopen(ev)); + this.ws.addEventListener("close", ev => this.onclose(ev)); + + return true; + } + + ondisconnect() { + this.wsState = WebSocket.CLOSED; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.pcState = WebSocket.CLOSED; + if (this.pc) { + this.pc.close(); + this.pc = null; + } + } + + /** + * @returns {Array.} of modes (mse, webrtc, etc.) + */ + onopen() { + // CONNECTING => OPEN + this.wsState = WebSocket.OPEN; + + this.ws.addEventListener("message", ev => { + if (typeof ev.data === "string") { + const msg = JSON.parse(ev.data); + for (const mode in this.onmessage) { + this.onmessage[mode](msg); + } + } else { + this.ondata(ev.data); + } + }); + + this.ondata = null; + this.onmessage = {}; + + const modes = []; + + if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone + modes.push("mse"); + this.onmse(); + } else if (this.mode.indexOf("mp4") >= 0) { + modes.push("mp4"); + this.onmp4(); + } + + if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app + modes.push("webrtc"); + this.onwebrtc(); + } + + if (this.mode.indexOf("mjpeg") >= 0) { + if (modes.length) { + this.onmessage["mjpeg"] = msg => { + if (msg.type !== "error" || msg.value.indexOf(modes[0]) !== 0) return; + this.onmjpeg(); + } + } else { + modes.push("mjpeg"); + this.onmjpeg(); + } + } + + return modes; + } + + /** + * @return {boolean} true if reconnection has started. + */ + onclose() { + if (this.wsState === WebSocket.CLOSED) return false; + + // CONNECTING, OPEN => CONNECTING + this.wsState = WebSocket.CONNECTING; + this.ws = null; + + // reconnect no more than once every X seconds + const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0); + + this.reconnectTID = setTimeout(() => { + this.reconnectTID = 0; + this.onconnect(); + }, delay); + + return true; + } + + onmse() { + const ms = new MediaSource(); + ms.addEventListener("sourceopen", () => { + URL.revokeObjectURL(this.video.src); + this.send({type: "mse", value: this.codecs("mse")}); + }, {once: true}); + + this.video.src = URL.createObjectURL(ms); + this.video.srcObject = null; + this.play(); + + this.mseCodecs = ""; + + this.onmessage["mse"] = msg => { + if (msg.type !== "mse") return; + + this.mseCodecs = msg.value; + + const sb = ms.addSourceBuffer(msg.value); + sb.mode = "segments"; // segments or sequence + sb.addEventListener("updateend", () => { + if (sb.updating) return; + + try { + if (bufLen > 0) { + const data = buf.slice(0, bufLen); + bufLen = 0; + sb.appendBuffer(data); + } else if (sb.buffered && sb.buffered.length) { + const end = sb.buffered.end(sb.buffered.length - 1) - 15; + const start = sb.buffered.start(0); + if (end > start) { + sb.remove(start, end); + ms.setLiveSeekableRange(end, end + 15); + } + // console.debug("VideoRTC.buffered", start, end); + } + } catch (e) { + // console.debug(e); + } + }); + + const buf = new Uint8Array(2 * 1024 * 1024); + let bufLen = 0; + + this.ondata = data => { + if (sb.updating || bufLen > 0) { + const b = new Uint8Array(data); + buf.set(b, bufLen); + bufLen += b.byteLength; + // console.debug("VideoRTC.buffer", b.byteLength, bufLen); + } else { + try { + sb.appendBuffer(data); + } catch (e) { + // console.debug(e); + } + } + } + } + } + + onwebrtc() { + const pc = new RTCPeerConnection(this.pcConfig); + + /** @type {HTMLVideoElement} */ + const video2 = document.createElement("video"); + video2.addEventListener("loadeddata", ev => this.onpcvideo(ev), {once: true}); + + pc.addEventListener("icecandidate", ev => { + const candidate = ev.candidate ? ev.candidate.toJSON().candidate : ""; + this.send({type: "webrtc/candidate", value: candidate}); + }); + + pc.addEventListener("track", ev => { + // when stream already init + if (video2.srcObject !== null) return; + + // when audio track not exist in Chrome + if (ev.streams.length === 0) return; + + // when audio track not exist in Firefox + if (ev.streams[0].id[0] === '{') return; + + video2.srcObject = ev.streams[0]; + }); + + pc.addEventListener("connectionstatechange", () => { + if (pc.connectionState === "failed" || pc.connectionState === "disconnected") { + pc.close(); // stop next events + + this.pcState = WebSocket.CLOSED; + this.pc = null; + + this.onconnect(); + } + }); + + this.onmessage["webrtc"] = msg => { + switch (msg.type) { + case "webrtc/candidate": + pc.addIceCandidate({ + candidate: msg.value, + sdpMid: "0" + }).catch(() => console.debug); + break; + case "webrtc/answer": + pc.setRemoteDescription({ + type: "answer", + sdp: msg.value + }).catch(() => console.debug); + break; + case "error": + if (msg.value.indexOf("webrtc/offer") < 0) return; + pc.close(); + } + }; + + // Safari doesn't support "offerToReceiveVideo" + pc.addTransceiver("video", {direction: "recvonly"}); + pc.addTransceiver("audio", {direction: "recvonly"}); + + pc.createOffer().then(offer => { + pc.setLocalDescription(offer).then(() => { + this.send({type: "webrtc/offer", value: offer.sdp}); + }); + }); + + this.pcState = WebSocket.CONNECTING; + this.pc = pc; + } + + /** + * @param ev {Event} + */ + onpcvideo(ev) { + if (!this.pc) return; + + /** @type {HTMLVideoElement} */ + const video2 = ev.target; + const state = this.pc.connectionState; + + // Firefox doesn't support pc.connectionState + if (state === "connected" || state === "connecting" || !state) { + // Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE + let rtcPriority = 0, msePriority = 0; + + /** @type {MediaStream} */ + const ms = video2.srcObject; + if (ms.getVideoTracks().length > 0) rtcPriority += 0x220; + if (ms.getAudioTracks().length > 0) rtcPriority += 0x102; + + if (this.mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230; + if (this.mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210; + if (this.mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101; + + if (rtcPriority >= msePriority) { + this.video.srcObject = ms; + this.play(); + + this.pcState = WebSocket.OPEN; + + this.wsState = WebSocket.CLOSED; + this.ws.close(); + this.ws = null; + } else { + this.pcState = WebSocket.CLOSED; + this.pc.close(); + this.pc = null; + } + } + + video2.srcObject = null; + } + + onmjpeg() { + this.ondata = data => { + this.video.controls = false; + this.video.poster = "data:image/jpeg;base64," + VideoRTC.btoa(data); + }; + + this.send({type: "mjpeg"}); + } + + onmp4() { + /** @type {HTMLCanvasElement} **/ + const canvas = document.createElement("canvas"); + /** @type {CanvasRenderingContext2D} */ + let context; + + /** @type {HTMLVideoElement} */ + const video2 = document.createElement("video"); + video2.autoplay = true; + video2.playsInline = true; + video2.muted = true; + + video2.addEventListener("loadeddata", ev => { + if (!context) { + canvas.width = video2.videoWidth; + canvas.height = video2.videoHeight; + context = canvas.getContext('2d'); + } + + context.drawImage(video2, 0, 0, canvas.width, canvas.height); + + this.video.controls = false; + this.video.poster = canvas.toDataURL("image/jpeg"); + }); + + this.ondata = data => { + video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data); + }; + + this.send({type: "mp4", value: this.codecs("mp4")}); + } + + static btoa(buffer) { + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + let binary = ""; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index fb13f480..c4d70334 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -3,10 +3,7 @@ "frigate_card": "Frigate card", "frigate_card_description": "A Lovelace card for use with Frigate", "live": "Live", - "no_clip": "No recent clip", - "no_clips": "No clips", - "no_snapshot": "No recent snapshot", - "no_snapshots": "No snapshots", + "no_media": "No media to display", "recordings": "Recordings", "version": "Version" }, @@ -16,39 +13,152 @@ "dependencies": { "all_cameras": "Show events for all cameras with this camera", "cameras": "Show events for specific cameras with this camera", - "options": "Dependency Options" + "editor_label": "Dependency Options" + }, + "engines": { + "editor_label": "Camera engine 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", + "editor_label": "Frigate Options", + "labels": "Frigate labels/object filters", "url": "Frigate server URL", - "zone": "Frigate zone" + "zones": "Frigate zones" }, + "go2rtc": { + "editor_label": "go2rtc Options", + "modes": { + "editor_label": "go2rtc Modes", + "mjpeg": "Motion JPEG (MJPEG)", + "mp4": "MPEG-4 (MP4)", + "mse": "Media Source Extensions (MSE)", + "webrtc": "Web Real-Time Communication (WebRTC)" + }, + "stream": "go2rtc stream name" + }, + "hide": "Hide camera from UI", "icon": "Icon for this camera (Autodetected from entity)", "id": "Unique id for this camera in this card", + "image": { + "editor_label": "Image Options", + "refresh_seconds": "Number of seconds after which to refresh live image (0=never)", + "url": "Image URL to use instead of camera entity snapshot" + }, "live_provider": "Live view provider for this camera", + "live_provider_options": { + "editor_label": "Live provider options" + }, "live_providers": { "auto": "Automatic", - "frigate-jsmpeg": "Frigate JSMpeg", - "ha": "Home Assistant (i.e. HLS, LL-HLS, WebRTC native)", + "go2rtc": "go2rtc", + "ha": "Home Assistant video stream (i.e. HLS, LL-HLS, WebRTC via HA)", + "image": "Home Assistant images", + "jsmpeg": "JSMpeg", "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" }, + "motioneye": { + "editor_label": "MotionEye Options", + "images": { + "directory_pattern": "Images directory pattern", + "file_pattern": "Images file pattern" + }, + "movies": { + "directory_pattern": "Movies directory pattern", + "file_pattern": "Movies file pattern" + }, + "url": "MotionEye UI URL" + }, "title": "Title for this camera (Autodetected from entity)", "triggers": { + "editor_label": "Trigger Options", "entities": "Trigger from other entities", "motion": "Trigger by auto-detecting the motion sensor", - "occupancy": "Trigger by auto-detecting the occupancy sensor", - "options": "Trigger Options" + "occupancy": "Trigger by auto-detecting the occupancy sensor" }, "webrtc_card": { + "editor_label": "WebRTC Card Options", "entity": "WebRTC Card Camera Entity (Not a Frigate camera)", - "options": "WebRTC Card Options", "url": "WebRTC Card Camera URL" } }, "common": { + "controls": { + "filter": { + "editor_label": "Media Filter", + "mode": "Filter mode", + "modes": { + "left": "Media filter in a drawer to the left", + "none": "No media filter", + "right": "Media filter in a drawer to the right" + } + }, + "next_previous": { + "editor_label": "Next & Previous", + "size": "Next & previous control size in pixels", + "style": "Next & previous control style", + "styles": { + "chevrons": "Chevrons", + "icons": "Icons", + "none": "None", + "thumbnails": "Thumbnails" + } + }, + "thumbnails": { + "editor_label": "Thumbnails", + "media": "Whether to show thumbnails of clips or snapshots", + "medias": { + "clips": "Clip thumbnails", + "snapshots": "Snapshot thumbnails" + }, + "mode": "Thumbnails mode", + "modes": { + "above": "Thumbnails above", + "below": "Thumbnails below", + "left": "Thumbnails in a drawer to the left", + "none": "No thumbnails", + "right": "Thumbnails in a drawer to the right" + }, + "show_details": "Show details with thumbnails", + "show_download_control": "Show download control on thumbnails", + "show_favorite_control": "Show favorite control on thumbnails", + "show_timeline_control": "Show timeline control on thumbnails", + "size": "Thumbnails size in pixels" + }, + "timeline": { + "editor_label": "Mini Timeline", + "mode": "Mode", + "modes": { + "above": "Above", + "below": "Below", + "none": "None" + } + }, + "title": { + "duration_seconds": "Seconds to display popup title (0=forever)", + "editor_label": "Popup Title Controls", + "mode": "Popup 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" + } + } + }, + "layout": { + "fit": "Layout fit", + "fits": { + "contain": "Media is contained/letterboxed", + "cover": "Media expands proportionally to cover the card", + "fill": "Media is stretched to fill the card" + }, + "position": { + "x": "Horizontal placement percentage", + "y": "Vertical placement percentage" + } + }, "media_action_conditions": { "all": "All opportunities", "hidden": "On browser/tab hiding", @@ -57,17 +167,21 @@ "unselected": "On unselection", "visible": "On browser/tab visibility" }, - "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" + "timeline": { + "clustering_threshold": "The count of events at which they are clustered (0=no clustering)", + "media": "The media the timeline displays", + "medias": { + "all": "All media types", + "clips": "Clips", + "snapshots": "Snapshots" }, - "position": { - "x": "Horizontal placement percentage", - "y": "Vertical placement percentage" - } + "show_recordings": "Show recordings", + "style": "Timeline style", + "styles": { + "ribbon": "Events on a single ribbon", + "stack": "Stacked & clustered events" + }, + "window_seconds": "The default length of the timeline view in seconds" } }, "dimensions": { @@ -77,18 +191,9 @@ "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" - } - } + }, + "max_height": "Maximum card height in CSS units (e.g. '100vh')", + "min_height": "Minimum card height in CSS units (e.g. '100px')" }, "image": { "layout": "Image Layout", @@ -107,32 +212,7 @@ "auto_play": "Automatically play live cameras", "auto_unmute": "Automatically unmute live cameras", "controls": { - "next_previous": { - "size": "Live view next & previous control size in pixels", - "style": "Live view next & previous control style", - "styles": { - "chevrons": "Chevrons", - "icons": "Icons", - "none": "None" - } - }, - "options": "Live Controls", - "thumbnails": { - "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": { - "duration_seconds": "Seconds to display popup title (0=forever)", - "mode": "Live media title display mode" - } + "editor_label": "Live Controls" }, "draggable": "Live cameras view can be dragged/swiped", "layout": "Live Layout", @@ -148,45 +228,12 @@ "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" - } - } + "editor_label": "Media Viewer Controls" }, "draggable": "Media Viewer can be dragged/swiped", - "lazy_load": "Media Viewer media is lazily loaded in carousel", "layout": "Media Viewer Layout", + "lazy_load": "Media Viewer media is lazily loaded in carousel", + "snapshot_click_plays_clip": "Clicking on a snapshot plays a related clip", "transition_effect": "Media Viewer transition effect", "transition_effects": { "none": "No transition", @@ -208,19 +255,22 @@ "matching": "Matching the menu alignment", "opposing": "Opposing the menu alignment" }, + "camera_ui": "Camera user interface", "cameras": "Cameras", "clips": "Clips", "download": "Download", "enabled": "Button enabled", + "expand": "Expand", "frigate": "Frigate menu / Default view", - "frigate_ui": "Frigate user interface", "fullscreen": "Fullscreen", "icon": "Icon", "image": "Image", "live": "Live", "media_player": "Send to media player", "priority": "Priority", + "recordings": "Recordings", "snapshots": "Snapshots", + "substreams": "Substream(s)", "timeline": "Timeline" }, "position": "Menu position", @@ -234,6 +284,7 @@ "styles": { "hidden": "Hidden menu", "hover": "Hover menu", + "hover-card": "Hover menu (card-wide)", "none": "No menu", "outside": "Outside menu", "overlay": "Overlay menu" @@ -242,26 +293,23 @@ "overrides": { "info": "This card configuration has manually specified overrides configured which may override values shown in the visual editor, please consult the code editor to view/modify these overrides" }, - "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" - } + "performance": { + "features": { + "animated_progress_indicator": "Animated Progress Indicator", + "editor_label": "Feature Options", + "media_chunk_size": "Media chunk size" }, - "media": "The media the timeline displays", - "medias": { - "all": "All media types", - "clips": "Clips", - "snapshots": "Snapshots" + "profile": "Performance profile", + "profiles": { + "high": "High/full performance", + "low": "Low performance" }, - "show_recordings": "Show recordings", - "window_seconds": "The default length of the timeline view in seconds" + "style": { + "border_radius": "Curves", + "box_shadow": "Shadows", + "editor_label": "Style Options" + }, + "warning": "This card is in low profile mode so defaults have changed to optimize performance" }, "view": { "camera_select": "View for newly selected cameras", @@ -289,6 +337,8 @@ "current": "Current view", "image": "Static image", "live": "Live view", + "recording": "Most recent recording", + "recordings": "Recordings gallery", "snapshot": "Most recent snapshot", "snapshots": "Snapshots gallery", "timeline": "Timeline view" @@ -304,12 +354,12 @@ "delete": "Delete", "dimensions": "Dimensions", "dimensions_secondary": "Dimensions & shape options", - "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_gallery": "Media gallery", + "media_gallery_secondary": "Media gallery options", "media_viewer": "Media viewer", "media_viewer_secondary": "Viewer for static media (clips, snapshots or recordings)", "menu": "Menu", @@ -318,6 +368,8 @@ "move_up": "Move up", "overrides": "Overrides are active", "overrides_secondary": "Dynamic configuration overrides detected", + "performance": "Performance", + "performance_secondary": "Card performance options", "timeline": "Timeline", "timeline_secondary": "Event timeline options", "upgrade": "Upgrade", @@ -325,11 +377,21 @@ "view": "View", "view_secondary": "What the card should show and how to show it" }, + "elements": { + "ptz": { + "down": "Down", + "home": "Home", + "left": "Left", + "right": "Right", + "up": "Up", + "zoom_in": "Zoom In", + "zoom_out": "Zoom Out" + } + }, "error": { "could_not_render_elements": "Could not render picture elements", "could_not_resolve": "Could not resolve media URL", "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", @@ -343,13 +405,16 @@ "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_no_endpoint": "Could not get camera endpoint for this live provider (incomplete configuration?)", "live_camera_not_found": "The configured camera_entity was not found", "live_camera_unavailable": "Camera unavailable", + "no_camera_engine": "Could not determine suitable engine for camera", + "no_camera_entity": "Could not find camera entity", + "no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers", "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", + "no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera", "reconnecting": "Reconnecting", "timeline_no_cameras": "No Frigate cameras to show in timeline", "troubleshooting": "Check troubleshooting", @@ -359,29 +424,65 @@ "webrtc_card_waiting": "Waiting for WebRTC Card to load ..." }, "event": { + "camera": "Camera", "duration": "Duration", "in_progress": "In Progress", "score": "Score", - "start": "Start" + "seek": "Seek", + "start": "Start", + "tag": "Tag", + "what": "What", + "where": "Where" + }, + "media_filter": { + "all": "All", + "camera": "Camera", + "favorite": "Favorite", + "media_type": "Media Type", + "media_types": { + "clips": "Clips", + "recordings": "Recordings", + "snapshots": "Snapshots" + }, + "not_favorite": "Not Favorite", + "select_camera": "Select camera...", + "select_favorite": "Select favorite...", + "select_media_type": "Select media type...", + "select_tag": "Select tag...", + "select_what": "Select what...", + "select_when": "Select when...", + "select_where": "Select where...", + "tag": "Tag", + "what": "What", + "when": "When", + "whens": { + "past_month": "Past Month", + "past_week": "Past Week", + "today": "Today", + "yesterday": "Yesterday" + }, + "where": "Where" }, "recording": { + "camera": "Camera", + "duration": "Duration", "events": "Events", - "seek": "Seek" + "in_progress": "In Progress", + "seek": "Seek", + "start": "Start" }, "thumbnail": { + "download": "Download media", "no_thumbnail": "No thumbnail available", - "retain_indefinitely": "Event will be indefinitely retained", - "timeline": "See event in timeline" + "retain_indefinitely": "Media will be indefinitely retained", + "timeline": "See media in timeline" }, - "elements": { - "ptz": { - "up": "Up", - "down": "Down", - "left": "Left", - "right": "Right", - "zoom_in": "Zoom In", - "zoom_out": "Zoom Out", - "home": "Home" - } + "timeline": { + "pan_behavior": { + "pan": "Pan", + "seek": "Pan seeks across all media", + "seek-in-media": "Pan seeks within selected media item only" + }, + "select_date": "Choose date" } -} +} \ No newline at end of file diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index bbca08af..500571fd 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -3,10 +3,7 @@ "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", + "no_media": "Nessun contenuto multimediale da visualizzare", "recordings": "Registrazioni", "version": "Versione" }, @@ -16,39 +13,152 @@ "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" + "editor_label": "Opzioni di dipendenza" + }, + "engines": { + "editor_label": "Opzioni del motore della fotocamera" }, "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", + "editor_label": "Frigate Opzione", + "labels": "Etichette per fregate/filtri per oggetti", "url": "Frigate URL del server", - "zone": "Frigate zona" + "zones": "Frigate Zone" }, + "go2rtc": { + "editor_label": "Opzioni go2rtc", + "modes": { + "editor_label": "Modalità go2rtc", + "mjpeg": "JPEG animato (MJPEG)", + "mp4": "MPEG-4 (MP4)", + "mse": "Estensioni sorgente multimediale (MSE)", + "webrtc": "Comunicazione Web in tempo reale (WebRTC)" + }, + "stream": "nome del flusso go2rtc" + }, + "hide": "Nascondi la videocamera dall'interfaccia utente", "icon": "Icona per questa telecamera (Autoidentificato dall'entità)", "id": "ID univoco per questa telecamera in questa carta", + "image": { + "editor_label": "Opzioni immagine", + "refresh_seconds": "Numero di secondi dopo i quali aggiornare l'immagine live (0=mai)", + "url": "URL dell'immagine da utilizzare al posto dell'istantanea dell'entità fotocamera" + }, "live_provider": "Provider di visualizzazione dal vivo per questa telecamera", + "live_provider_options": { + "editor_label": "Opzioni del fornitore in tempo reale" + }, "live_providers": { "auto": "Automatica", - "frigate-jsmpeg": "Frigate JSMpeg", - "ha": "Home Assistant (ovvero HLS, LL-HLS, WebRTC nativo)", + "go2rtc": "go2rtc", + "ha": "Streaming video di Home Assistant (ovvero HLS, LL-HLS, WebRTC tramite HA)", + "image": "Immagini Home Assistant", + "jsmpeg": "JSMpeg", "webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)" }, + "motioneye": { + "editor_label": "Opzioni di MotionEye", + "images": { + "directory_pattern": "Modello di directory delle immagini", + "file_pattern": "Modello di file di immagini" + }, + "movies": { + "directory_pattern": "Modello di directory dei film", + "file_pattern": "Modello di file di film" + }, + "url": "URL dell'interfaccia utente di MotionEye" + }, "title": "Titolo per questa telecamera (Autoidentificato dall'entità)", "triggers": { + "editor_label": "Trigger Opzioni", "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" + "occupancy": "Attivare rilevando automatico tramite il sensore di presenza" }, "webrtc_card": { + "editor_label": "Opzioni della scheda WebRTC", "entity": "Entità della telecamera della scheda WebRTC (non una telecamera Frigate)", - "options": "Opzioni della scheda WebRTC", "url": "URL della telecamera della scheda WebRTC" } }, "common": { + "controls": { + "filter": { + "editor_label": "Filtro multimediale", + "mode": "Modalità filtro", + "modes": { + "left": "Filtro multimediale in un cassetto a sinistra", + "none": "Nessun filtro multimediale", + "right": "Filtro multimediale in un cassetto a destra" + } + }, + "next_previous": { + "editor_label": "Successivo e precedente", + "size": "Successiva e Precedenti dimensioni di controllo nei pixel", + "style": "Stile di controllo successivo e precedente", + "styles": { + "chevrons": "Chevrons", + "icons": "Icone", + "none": "Nessuno", + "thumbnails": "Miniature" + } + }, + "thumbnails": { + "editor_label": "Miniature", + "media": "Se mostrare miniature di clip o istantanee", + "medias": { + "clips": "Miniature di clip", + "snapshots": "Miniature istantanee" + }, + "mode": "Modalità miniatura", + "modes": { + "above": "Miniature sopra", + "below": "Miniature sotto", + "left": "Miniature in un cassetto a sinistra", + "none": "Nessuna miniatura", + "right": "Miniature in un cassetto a destra" + }, + "show_details": "Mostra i dettagli con le miniature", + "show_download_control": "Mostra il controllo del download sulle 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 in pixel" + }, + "timeline": { + "editor_label": "Mini Cronologia", + "mode": "Modalità", + "modes": { + "above": "sopra", + "below": "sotto", + "none": "sessuna" + } + }, + "title": { + "duration_seconds": "Secondi per visualizzare il titolo popup (0 = per sempre)", + "editor_label": "Controlli titolo popup", + "mode": "Modalità di visualizzazione del titolo", + "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" + } + } + }, + "layout": { + "fit": "Adatta al layout", + "fits": { + "contain": "Il supporto è contenuto/in cassetta delle lettere", + "cover": "Il supporto si espande proporzionalmente per coprire la scheda", + "fill": "Il supporto viene allungato per riempire la scheda" + }, + "position": { + "x": "Percentuale di posizionamento orizzontale", + "y": "Percentuale di posizionamento verticale" + } + }, "media_action_conditions": { "all": "Tutte le opportunità", "hidden": "Sul browser/nascondere le schede", @@ -56,6 +166,22 @@ "selected": "Sulla selezione", "unselected": "Sulla non selezione", "visible": "Sul browser/visibilità della scheda" + }, + "timeline": { + "clustering_threshold": "Il conteggio degli eventi in cui sono raggruppati (0 = nessun clustering)", + "media": "I media vengono visualizzati la sequenza temporale", + "medias": { + "all": "Tutti i tipi di media", + "clips": "Clip", + "snapshots": "Istantanee" + }, + "show_recordings": "Mostra registrazioni", + "style": "", + "styles": { + "ribbon": "", + "stack": "" + }, + "window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi" } }, "dimensions": { @@ -65,20 +191,12 @@ "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" - } - } + }, + "max_height": "", + "min_height": "" }, "image": { + "layout": "Disposizione dell'immagine", "mode": "Modalità Visualizza immagine", "modes": { "camera": "Istantanea della telecamera di Home Assistant dell'entità telecamera", @@ -94,38 +212,14 @@ "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" - } + "editor_label": "Controlli dal vivo" }, "draggable": "Il Visualizzatore eventi può essere trascinato oppure puoi scorrere", + "layout": "Disposizione dal vivo", "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": "", + "show_image_during_load": "Mostra un'immagine fissa durante il caricamento del live streaming", "transition_effect": "Effetto di transizione della telecamera dal vivo" }, "media_viewer": { @@ -134,43 +228,10 @@ "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" - } - } + "editor_label": "Controlli di visualizzatore multimediale" }, "draggable": "Il visualizzatore multimediale può essere trascinato oppure può scorrere", + "layout": "Layout del visualizzatore multimediale", "lazy_load": "Il media Viewer viene caricato pigramente nel carosello", "transition_effect": "Effetto di transizione del visualizzatore multimediale", "transition_effects": { @@ -193,12 +254,13 @@ "matching": "Corrispondenza con l'allineamento del menu", "opposing": "Contrastare l'allineamento del menu" }, + "camera_ui": "Interfaccia utente della fotocamera", "cameras": "Telecamere", "clips": "Clip", "download": "Download", "enabled": "Pulsante abilitato", + "expand": "Espandere", "frigate": "Frigate menu / Visualizzazione predefinita", - "frigate_ui": "Frigate interfaccia utente", "fullscreen": "A schermo intero", "icon": "Icona", "image": "Immagine", @@ -206,6 +268,7 @@ "media_player": "Invia a Media Player", "priority": "Priorità", "snapshots": "Istantanee", + "substreams": "Flusso/i secondario/i", "timeline": "Timeline" }, "position": "Posizione del menu", @@ -227,26 +290,23 @@ "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" - } + "performance": { + "features": { + "animated_progress_indicator": "Indicatore di avanzamento animato", + "editor_label": "Opzioni funzionalità", + "media_chunk_size": "Dimensione del blocco multimediale" }, - "media": "I media vengono visualizzati la sequenza temporale", - "medias": { - "all": "Tutti i tipi di media", - "clips": "Clip", - "snapshots": "Istantanee" + "profile": "Profilo delle prestazioni", + "profiles": { + "high": "Prestazioni alte", + "low": "Prestazioni basse" }, - "show_recordings": "Mostra registrazioni", - "window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi" + "style": { + "border_radius": "Curve", + "box_shadow": "Ombre", + "editor_label": "Opzione di stile" + }, + "warning": "Questa scheda è in modalità basso profilo, quindi le impostazioni predefinite sono state modificate per ottimizzare le prestazioni" }, "view": { "camera_select": "Visualizza per le telecamere appena selezionate", @@ -289,12 +349,12 @@ "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_gallery": "Galleria multimediale", + "media_gallery_secondary": "Opzioni della galleria multimediale", "media_viewer": "Visualizzatore dei media", "media_viewer_secondary": "Visualizzatore per supporti statici (clip, istantanee o registrazioni)", "menu": "Menu", @@ -310,11 +370,21 @@ "view": "Visualizzazione", "view_secondary": "Cosa dovrebbe mostrare la carta e come mostrarla" }, + "elements": { + "ptz": { + "down": "Giù", + "home": "Home", + "left": "Sinistra", + "right": "Destra", + "up": "Su", + "zoom_in": "Ingrandire", + "zoom_out": "Zoom indietro" + } + }, "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", @@ -328,13 +398,16 @@ "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_no_endpoint": "Impossibile ottenere l'endpoint della videocamera per questo provider live (configurazione incompleta?)", "live_camera_not_found": "La telecamera configurata non è stata trovata", "live_camera_unavailable": "Telecamera non disponibile", + "no_camera_engine": "Impossibile determinare il motore adatto per la fotocamera", + "no_camera_entity": "Impossibile trovare l'entità fotocamera", + "no_camera_entity_for_triggers": "È necessaria un'entità telecamera per rilevare automaticamente i trigger", "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", + "no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta", "reconnecting": "Riconnessione", "timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline", "troubleshooting": "Controllare la risoluzione dei problemi", @@ -344,18 +417,62 @@ "webrtc_card_waiting": "Aspettando che la scheda WebRTC si carichi ..." }, "event": { + "camera": "Camera", "duration": "Durata", "in_progress": "In corso", "score": "Punteggio", - "start": "Avvia" + "seek": "Cercare", + "start": "Avvia", + "what": "Che cosa", + "where": "Dove" + }, + "media_filter": { + "all": "Tutto", + "camera": "Telecamera", + "favorite": "Preferito", + "media_type": "Tipo di supporto", + "media_types": { + "clips": "Clip", + "recordings": "Registrazioni", + "snapshots": "Istantanee" + }, + "not_favorite": "Non preferito", + "select_camera": "Seleziona fotocamera...", + "select_favorite": "Seleziona preferito...", + "select_media_type": "Seleziona il tipo di supporto...", + "select_what": "Seleziona cosa...", + "select_when": "Seleziona quando...", + "select_where": "Seleziona dove...", + "tag": "Tag", + "what": "Che cosa", + "when": "Quando", + "whens": { + "past_month": "Mese scorso", + "past_week": "Settimana scorso", + "today": "Oggi", + "yesterday": "Ieri" + }, + "where": "Dove" }, "recording": { + "camera": "Camera", + "duration": "Durata", "events": "Eventi", - "seek": "Cercare" + "in_progress": "In corso", + "seek": "Cercare", + "start": "Inizio" }, "thumbnail": { "no_thumbnail": "Nessuna miniatura disponibile", "retain_indefinitely": "L'evento sarà mantenuto indefinitamente", "timeline": "Vedi evento nella timeline" + }, + "timeline": { + "pan_behavior": { + "pan": "", + "seek": "", + "seek-in-media": "" + }, + "select_date": "Scegli la data" } -} +} \ No newline at end of file diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 79aa2999..cbb19e24 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -3,10 +3,7 @@ "frigate_card": "Cartão Frigate", "frigate_card_description": "Um cartão da Lovelace para usar com Frigate", "live": "Ao Vivo", - "no_clip": "Sem clip recente", - "no_clips": "Sem clips", - "no_snapshot": "Sem snapshot recente", - "no_snapshots": "Sem snapshots", + "no_media": "Nenhuma mídia para exibir", "recordings": "Gravações", "version": "Versão" }, @@ -16,39 +13,152 @@ "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" + "editor_label": "Opções de dependência" + }, + "engines": { + "editor_label": "Opções do motor da câmera" }, "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", + "editor_label": "Opções do Frigate", + "labels": "Rótulos do Frigate/filtros de objetos", "url": "URL do servidor Frigate", - "zone": "Zona do Frigate" + "zones": "Zonas do Frigate" }, + "go2rtc": { + "editor_label": "Opções do go2rtc", + "modes": { + "editor_label": "Modos do go2rtc", + "mjpeg": "Motion JPEG (MJPEG)", + "mp4": "MPEG-4 (MP4)", + "mse": "Media Source Extensions (MSE)", + "webrtc": "Web Real-Time Communication (WebRTC)" + }, + "stream": "Nome do stream do go2rtc" + }, + "hide": "Ocultar câmera da interface do usuário", "icon": "Ícone para esta câmera (detectado automaticamente pela entidade)", "id": "ID exclusivo para esta câmera nesse cartão", + "image": { + "editor_label": "Opções de Imagem", + "refresh_seconds": "Número de segundos após os quais atualizar a imagem ao vivo (0=nunca)", + "url": "URL da imagem para usar em vez do instantâneo da entidade da câmera" + }, "live_provider": "Provedor de visualização ao vivo para esta câmera", + "live_provider_options": { + "editor_label": "Opções do provedor de visualização ao vivo" + }, "live_providers": { "auto": "Automatico", - "frigate-jsmpeg": "Frigate JSMpeg", - "ha": "Home Assistant (HLS, LL-HLS ou WebRTC nativo)", + "go2rtc": "go2rtc", + "ha": "Stream de vídeo do Home Assistant (ou seja, HLS, LL-HLS, WebRTC via HA)", + "image": "Imagens do Home Assistant", + "jsmpeg": "JSMpeg", "webrtc-card": "Cartão WebRTC (de @AlexxIT)" }, + "motioneye": { + "editor_label": "Opções do MotionEye", + "images": { + "directory_pattern": "Padrão de diretório de imagens", + "file_pattern": "Padrão de arquivo de imagens" + }, + "movies": { + "directory_pattern": "Padrão de diretório de filmes", + "file_pattern": "Padrão de arquivo de filmes" + }, + "url": "URL da interface de usuário do MotionEye" + }, "title": "Título para esta câmera (detectado automaticamente pela entidade)", "triggers": { + "editor_label": "Opções de acionamento", "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" + "occupancy": "Acionar detectando automaticamente o sensor de ocupação" }, "webrtc_card": { + "editor_label": "Opções do cartão WebRTC", "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" } }, "common": { + "controls": { + "filter": { + "editor_label": "Filtro de Mídia", + "mode": "Modo do filtro", + "modes": { + "left": "Filtro de mídia em uma gaveta à esquerda", + "none": "Sem filtro de mídia", + "right": "Filtro de mídia em uma gaveta à direita" + } + }, + "next_previous": { + "editor_label": "Próximo", + "size": "Tamanho de controle próximo e anterior", + "style": "Estilo do controle próximo e anterior", + "styles": { + "chevrons": "Setas", + "icons": "Ícones", + "none": "Nenhum", + "thumbnails": "Miniaturas" + } + }, + "thumbnails": { + "editor_label": "Miniaturas", + "media": "Se deve mostrar miniaturas de clipes ou snapshots", + "medias": { + "clips": "Miniaturas de clipes", + "snapshots": "Miniaturas de Snapshots" + }, + "mode": "Modo de miniaturas", + "modes": { + "above": "Miniaturas acima da mídia", + "below": "Miniaturas abaixo da mídia", + "left": "Miniaturas em uma gaveta à esquerda", + "none": "Sem miniaturas", + "right": "Miniaturas em uma gaveta à direita" + }, + "show_details": "Mostrar detalhes com miniaturas", + "show_download_control": "Mostrar controle de download nas 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 em pixels" + }, + "timeline": { + "editor_label": "Controles da linha do tempo", + "mode": "Modo", + "modes": { + "above": "Acima", + "below": "Abaixo", + "none": "Nenhum" + } + }, + "title": { + "duration_seconds": "Segundos para exibir o pop-up (0 = para sempre)", + "editor_label": "Controles do pop-up de título", + "mode": "Modo de exibição de título de mídia", + "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" + } + } + }, + "layout": { + "fit": "Ajuste de layout", + "fits": { + "contain": "A mídia é contida no cartão", + "cover": "A mídia se expande proporcionalmente para cobrir o cartão", + "fill": "A mídia é esticada para preencher o cartão" + }, + "position": { + "x": "Porcentagem do posicionamento horizontal", + "y": "Porcentagem do posicionamento vertical" + } + }, "media_action_conditions": { "all": "Todas as oportunidades", "hidden": "Ao ocultar o navegador/aba", @@ -56,6 +166,22 @@ "selected": "Ao selecionar", "unselected": "Ao desselecionar", "visible": "Ao mostrar o navegador/aba" + }, + "timeline": { + "clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)", + "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", + "style": "", + "styles": { + "ribbon": "", + "stack": "" + }, + "window_seconds": "A duração padrão da visualização da linha do tempo em segundos" } }, "dimensions": { @@ -65,20 +191,12 @@ "dynamic": "A proporção se ajusta à mídia", "static": "Proporção estática", "unconstrained": "Proporção irrestrita" - } - }, - "event_gallery": { - "controls": { - "options": "Controles da Galeria de Eventos", - "thumbnails": { - "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" - } - } + }, + "max_height": "", + "min_height": "" }, "image": { + "layout": "Layout da imagem", "mode": "Modo de visualização de imagem", "modes": { "camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera", @@ -94,38 +212,14 @@ "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" - } + "editor_label": "Controles da visualização ao vivo" }, "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada", + "layout": "Layout dinâmico", "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": "", + "show_image_during_load": "Mostrar imagem estática enquanto a transmissão ao vivo está carregando", "transition_effect": "Efeito de transição de câmera ao vivo" }, "media_viewer": { @@ -134,44 +228,12 @@ "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" - } - } + "editor_label": "Controles do visualizador de mídia" }, "draggable": "Visualizador de eventos pode ser arrastado/deslizado", + "layout": "Layout do visualizador de mídia", "lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel", + "snapshot_click_plays_clip": "Clicar em um instantâneo reproduz um clipe relacionado", "transition_effect": "Efeito de transição do Visualizador de eventos", "transition_effects": { "none": "Sem transição", @@ -193,19 +255,22 @@ "matching": "Mesmo alinhamento do menu", "opposing": "Opor-se ao alinhamento do menu" }, + "camera_ui": "Interface de usuário da câmera", "cameras": "Selecionar câmera", "clips": "Clipes", "download": "Baixe a mídia do evento", "enabled": "Botão ativado", + "expand": "Expandir", "frigate": "Frigate menu / Visualização padrão", - "frigate_ui": "Frigate Interface de usuário", "fullscreen": "Tela cheia", "icon": "Ícone", "image": "Imagem", "live": "Ao vivo", "media_player": "Enviar para o reprodutor de mídia", "priority": "Prioridade", + "recordings": "Gravações", "snapshots": "Instantâneos", + "substreams": "Substream(s)", "timeline": "Linha do tempo" }, "position": "Posição do menu", @@ -219,6 +284,7 @@ "styles": { "hidden": "Menu oculto", "hover": "Menu suspenso", + "hover-card": "Menu suspenso (em todo o cartão)", "none": "Sem menu", "outside": "Menu externo", "overlay": "Menu sobreposto" @@ -227,26 +293,23 @@ "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" - } + "performance": { + "features": { + "animated_progress_indicator": "Indicador de Carregamento Animado", + "editor_label": "Opções de recursos", + "media_chunk_size": "Tamanho do bloco de mídia" }, - "media": "A mídia que a linha do tempo exibe", - "medias": { - "all": "Todos os tipos de mídia", - "clips": "Clipes", - "snapshots": "Instantâneos" + "profile": "Perfil de desempenho", + "profiles": { + "high": "Alto desempenho/completo", + "low": "Baixo desempenho" }, - "show_recordings": "Mostrar gravações", - "window_seconds": "A duração padrão da visualização da linha do tempo em segundos" + "style": { + "border_radius": "Curvas", + "box_shadow": "Sombras", + "editor_label": "Opções de estilo" + }, + "warning": "Este cartão está no modo de baixo desempenho, então os padrões foram alterados para otimizar o desempenho" }, "view": { "camera_select": "Visualização de câmeras recém-selecionadas", @@ -274,6 +337,8 @@ "current": "Visualização atual", "image": "Imagem estática", "live": "Visualização ao vivo", + "recording": "Gravação mais recente", + "recordings": "Galeria de gravações", "snapshot": "Snapshot mais recente", "snapshots": "Galeria de Snapshots", "timeline": "Visualização da linha do tempo" @@ -289,12 +354,12 @@ "delete": "Excluir", "dimensions": "Dimensões", "dimensions_secondary": "Dimensões e opções de forma", - "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_gallery": "Galeria de mídia", + "media_gallery_secondary": "Opções da galeria de mídia", "media_viewer": "Visualizador de eventos", "media_viewer_secondary": "Opções do visualizador de Snapshots e clipes", "menu": "Menu", @@ -303,6 +368,8 @@ "move_up": "Subir", "overrides": "As substituições estão ativas", "overrides_secondary": "Substituições de configuração dinâmica detectadas", + "performance": "Desempenho", + "performance_secondary": "Opções de desempenho do cartão", "timeline": "Linha do tempo", "timeline_secondary": "Opções do evento da linha do tempo", "upgrade": "Upgrade", @@ -310,11 +377,21 @@ "view": "Visualizar", "view_secondary": "O que o cartão deve mostrar e como mostrá-lo" }, + "elements": { + "ptz": { + "down": "Baixo", + "home": "Casa", + "left": "Esquerda", + "right": "Direita", + "up": "Cima", + "zoom_in": "Aumentar Zoom", + "zoom_out": "Reduzir Zoom" + } + }, "error": { "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", "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", @@ -328,13 +405,16 @@ "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": "", + "live_camera_no_endpoint": "Não foi possível obter o endereço da câmera para este provedor ao vivo (configuração incompleta?)", + "live_camera_not_found": "A entidade de câmera configurada não foi encontrada", + "live_camera_unavailable": "Câmera indisponível", + "no_camera_engine": "Não foi possível determinar o motor adequado para a câmera", + "no_camera_entity": "Não foi possível encontrar a entidade da câmera", + "no_camera_entity_for_triggers": "Uma entidade de câmera é necessária para detectar automaticamente os gatilhos", "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", + "no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta", "reconnecting": "Reconectando", "timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo", "troubleshooting": "Verifique a solução de problemas", @@ -344,18 +424,65 @@ "webrtc_card_waiting": "Aguardando o cartão WebRTC carregar ..." }, "event": { + "camera": "Câmera", "duration": "Duração", "in_progress": "Em andamento", "score": "Pontuação", - "start": "Início" + "seek": "Procurar", + "start": "Início", + "tag": "Etiqueta", + "what": "O que", + "where": "Onde" + }, + "media_filter": { + "all": "Todos", + "camera": "Câmera", + "favorite": "Favorito", + "media_type": "Tipo de mídia", + "media_types": { + "clips": "Clipes", + "recordings": "Gravações", + "snapshots": "Instantâneos" + }, + "not_favorite": "Não favorito", + "select_camera": "Selecione a câmera...", + "select_favorite": "Selecione favorito...", + "select_media_type": "Selecione o tipo de mídia...", + "select_tag": "Selecione a etiqueta...", + "select_what": "Selecione o que...", + "select_when": "Selecione quando...", + "select_where": "Selecione onde...", + "tag": "Etiqueta", + "what": "O que", + "when": "Quando", + "whens": { + "past_month": "Mês passado", + "past_week": "Semana passada", + "today": "Hoje", + "yesterday": "Ontem" + }, + "where": "Onde" }, "recording": { + "camera": "Câmera", + "duration": "Duração", "events": "Eventos", - "seek": "Procurar" + "in_progress": "Em andamento", + "seek": "Procurar", + "start": "Começar" }, "thumbnail": { + "download": "Baixar mídia", "no_thumbnail": "Nenhuma miniatura disponível", "retain_indefinitely": "Evento será retido por tempo indeterminado", "timeline": "Ver evento na linha do tempo" + }, + "timeline": { + "pan_behavior": { + "pan": "", + "seek": "", + "seek-in-media": "" + }, + "select_date": "Escolha a data" } } diff --git a/src/localize/localize.ts b/src/localize/localize.ts index de9e9640..cd41f2b0 100644 --- a/src/localize/localize.ts +++ b/src/localize/localize.ts @@ -1,56 +1,88 @@ +import { HomeAssistant } from 'custom-card-helpers'; import * as en from './languages/en.json'; -import * as pt_BR from './languages/pt-BR.json'; -import * as it from './languages/it.json'; + +const DEFAULT_LANG = 'en' as const; // eslint-disable-next-line @typescript-eslint/no-explicit-any -const languages: any = { - en: en, - pt_BR: pt_BR, - it: it, +const languages: Record = { + // English as always loaded as it's the fallback language that will be used + // when translations are not found or before they are loaded (via + // loadLanguages()). + [DEFAULT_LANG]: en, }; -export function getLanguage(): string { - const canonicalizeLanguage = (language?: string | null): string | null => { - if (!language) { - return null; - } +// The language is calculated and stored once, then re-used to avoid needing to +// repeat the lookups and to ensure minimal information needs to be plumbed +// through on each localization call. +let frigateCardLanguage: string | undefined; + +/** + * Get the configured language. + */ +export function getLanguage(hass?: HomeAssistant): string { + const canonicalizeLanguage = (language: string): string => { return language.replace('-', '_'); }; - // 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; + // Try the hass language first... + const hassLanguage = hass?.language ?? hass?.selectedLanguage; + if (hassLanguage) { + return canonicalizeLanguage(hassLanguage); + } + + // Then the language that hass may have stored locally. + const storageLanguage = localStorage.getItem('selectedLanguage'); + if (storageLanguage) { + const parsedLanguage: string | null = JSON.parse(storageLanguage); + if (parsedLanguage) { + return canonicalizeLanguage(parsedLanguage); } } // 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; - } + for (const language of navigator.languages) { + const canonicalLanguage = canonicalizeLanguage(language); + if (canonicalLanguage && canonicalLanguage in languages) { + return canonicalLanguage; } } - return lang || 'en'; + return DEFAULT_LANG; } -export function localize(string: string, search = '', replace = ''): string { - const lang = getLanguage(); - let translated: string; - - try { - translated = string.split('.').reduce((o, i) => o[i], languages[lang]); - } catch (e) { - translated = string.split('.').reduce((o, i) => o[i], languages['en']); +/** + * Load required languages. + */ +export const loadLanguages = async (hass: HomeAssistant): Promise => { + const lang = getLanguage(hass); + if (lang === 'it') { + languages[lang] = await import('./languages/it.json'); + } else if (lang === 'pt_BR') { + languages[lang] = await import('./languages/pt-BR.json'); } + if (lang) { + frigateCardLanguage = lang; + } +}; + +/** + * Get a localized version of a given string key. + * @param string The key. + * @param search An optional search key to be used with 'replace'. + * @param replace An optional replacement text to be used with 'search'. + * @returns + */ +export function localize(string: string, search = '', replace = ''): string { + let translated = ''; + + try { + translated = string + .split('.') + .reduce((o, i) => o[i], languages[frigateCardLanguage ?? DEFAULT_LANG]); + } catch (_) {} + if (!translated) { - translated = string.split('.').reduce((o, i) => o[i], languages['en']); + translated = string.split('.').reduce((o, i) => o[i], languages[DEFAULT_LANG]); } if (search !== '' && replace !== '') { diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 2081dca5..9ce81467 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -28,20 +28,15 @@ customElements.whenDefined('ha-camera-stream').then(() => { const computeMJPEGStreamUrl = (entity: CameraEntity): string => `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`; - const computeObjectId = (entityId: string): string => - entityId.substr(entityId.indexOf('.') + 1); - - const computeStateName = (stateObj: HassEntity): string => - stateObj.attributes.friendly_name === undefined - ? computeObjectId(stateObj.entity_id).replace(/_/g, ' ') - : stateObj.attributes.friendly_name || ''; - const STREAM_TYPE_HLS = 'hls'; const STREAM_TYPE_WEB_RTC = 'web_rtc'; @customElement('frigate-card-ha-camera-stream') // eslint-disable-next-line @typescript-eslint/no-unused-vars - class FrigateCardHaCameraStream extends customElements.get('ha-camera-stream') { + class FrigateCardHaCameraStream + extends customElements.get('ha-camera-stream') + implements FrigateCardMediaPlayer + { // 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') @@ -52,38 +47,27 @@ customElements.whenDefined('ha-camera-stream').then(() => { // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-camera-stream.ts // ======================================================================================== - /** - * Play the video. - */ - public play(): void { - this._player?.play(); + public async play(): Promise { + return this._player?.play(); } - /** - * Pause the video. - */ - public pause(): void { + public async pause(): Promise { this._player?.pause(); } - /** - * Mute the video. - */ - public mute(): void { + public async mute(): Promise { this._player?.mute(); } - /** - * Unmute the video. - */ - public unmute(): void { + public async unmute(): Promise { this._player?.unmute(); } - /** - * Seek the video (unsupported). - */ - public seek(seconds: number): void { + public isMuted(): boolean { + return this._player?.isMuted() ?? true; + } + + public async seek(seconds: number): Promise { this._player?.seek(seconds); } @@ -105,7 +89,6 @@ customElements.whenDefined('ha-camera-stream').then(() => { .src=${typeof this._connected == 'undefined' || this._connected ? computeMJPEGStreamUrl(this.stateObj) : ''} - .alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`} /> `; } diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index fa910c91..38f8b148 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -15,34 +15,33 @@ 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'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, +} from '../utils/media.js'; +import { FrigateCardMediaPlayer } from '../types.js'; 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') { + class FrigateCardHaHlsPlayer + extends customElements.get('ha-hls-player') + implements FrigateCardMediaPlayer + { // 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._video?.play(); + public async play(): Promise { + return this._video?.play(); } - /** - * Pause the video. - */ - public pause(): void { + public async pause(): Promise { this._video?.pause(); } - /** - * Mute the video. - */ - public mute(): void { + public async mute(): Promise { // 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) { @@ -50,21 +49,20 @@ customElements.whenDefined('ha-hls-player').then(() => { } } - /** - * Unmute the video. - */ - public unmute(): void { + public async unmute(): Promise { // See note in mute(). if (this._video) { this._video.muted = false; } } - /** - * Seek the video. - */ - public seek(seconds: number): void { + public isMuted(): boolean { + return this._video?.muted ?? true; + } + + public async seek(seconds: number): Promise { if (this._video) { + hideMediaControlsTemporarily(this._video); this._video.currentTime = seconds; } } @@ -75,8 +73,12 @@ customElements.whenDefined('ha-hls-player').then(() => { // ===================================================================================== protected render(): TemplateResult { if (this._error) { - // Use native Frigate card error handling. - return dispatchErrorMessageEvent(this, this._error); + if (this._errorIsFatal) { + // Use native Frigate card error handling for fatal errors. + return dispatchErrorMessageEvent(this, this._error); + } else { + console.error(this._error); + } } return html`