From abcba884e54208ad626e82b1dc8e6843a4c772a6 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 18 May 2026 20:25:21 -0700 Subject: [PATCH] feat: Add 'call' support to improve 2-way audio experience (#2486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draws significant inspiration (and direct styling) from https://github.com/dermotduffy/advanced-camera-card/pull/2447 . Thank you @Maudfer ! BREAKING CHANGE: The microphone condition previously bundled two unrelated signals — whether a two-way-audio session was connected and whether the microphone was muted. Connection state is now its own dedicated call condition, and microphone is reserved purely for mute state. Configs are upgraded automatically (the card rewrites affected conditions under overrides, elements, and automations). If you maintain config by hand, convert as follows: If you only used connected: # Before ```yaml condition: microphone connected: true ``` # After ```yaml condition: call call: true ``` If you used both connected and muted — they must be split into two conditions, since they no longer live together: # Before ```yaml condition: microphone connected: true muted: false ``` # After ```yaml condition: and conditions: - condition: call call: true - condition: microphone muted: false ``` --- docs/configuration/actions/custom/README.md | 41 + docs/configuration/conditions.md | 42 +- docs/configuration/live.md | 88 ++- docs/configuration/media-viewer.md | 11 +- docs/configuration/menu.md | 45 +- docs/configuration/status-bar.md | 18 +- docs/images/call-sequence.svg | 1 + docs/uml/call-sequence.puml | 59 ++ docs/usage/2-way-audio.md | 82 +- package.json | 1 + src/camera-manager/store.ts | 16 +- .../actions/actions/call-end.ts | 11 + .../actions/actions/call-start.ts | 11 + .../actions/actions/media-player.ts | 2 +- .../actions/actions/substream-off.ts | 4 +- .../actions/actions/substream-on.ts | 38 +- .../actions/actions/substream-select.ts | 4 +- src/card-controller/actions/factory.ts | 6 + src/card-controller/call/manager.ts | 283 +++++++ src/card-controller/call/types.ts | 15 + src/card-controller/controller.ts | 12 +- .../{microphone-policy.ts => call-policy.ts} | 21 +- src/card-controller/lock/manager.ts | 4 +- src/card-controller/microphone-manager.ts | 7 - src/card-controller/query-string-manager.ts | 6 +- src/card-controller/types.ts | 14 +- .../view/modifiers/substream-off.ts | 9 - .../view/modifiers/substream-on.ts | 46 -- .../view/modifiers/substream-select.ts | 15 - .../view/modifiers/substream.ts | 30 + src/card-controller/view/view-manager.ts | 23 +- src/card.ts | 9 + src/components-lib/auto-hide.ts | 21 + .../live/microphone-actions-controller.ts | 22 + .../media-actions-controller.ts | 74 +- src/components-lib/menu-button-controller.ts | 82 +- src/components-lib/menu-controller.ts | 17 + src/components-lib/status-bar-controller.ts | 14 + src/components/call-controls.ts | 160 ++++ src/components/live/carousel.ts | 91 ++- src/components/live/grid.ts | 14 +- src/components/live/index.ts | 8 + src/components/live/provider.ts | 5 +- src/components/live/providers/go2rtc/index.ts | 9 +- src/components/menu.ts | 12 +- src/components/next-prev-control.ts | 13 +- src/components/notification/popup.ts | 3 +- src/components/status-bar.ts | 8 + src/components/viewer/carousel.ts | 2 + src/components/views.ts | 5 + src/conditions/conditions-manager.ts | 10 +- src/conditions/types.ts | 4 + src/config/management.ts | 52 ++ src/config/profiles/casting.ts | 10 +- src/config/schema/actions/custom/call-end.ts | 8 + .../schema/actions/custom/call-start.ts | 15 + src/config/schema/actions/types.ts | 4 + src/config/schema/common/auto-hide.ts | 4 + .../schema/common/controls/next-previous.ts | 2 + src/config/schema/common/media-actions.ts | 13 +- src/config/schema/conditions/custom/call.ts | 6 + .../schema/conditions/custom/microphone.ts | 3 +- src/config/schema/conditions/types.ts | 2 + src/config/schema/live.ts | 33 +- src/config/schema/menu.ts | 5 + src/config/schema/status-bar.ts | 6 + src/config/schema/viewer.ts | 7 + src/const.ts | 13 +- src/editor.ts | 95 ++- src/localize/languages/en.json | 27 +- src/scss/call-controls.scss | 56 ++ src/scss/elements.scss | 6 + src/scss/live-grid.scss | 29 +- src/scss/next-previous-control.scss | 12 +- src/scss/notification-popup.scss | 39 +- src/scss/pop-animation.scss | 46 ++ src/scss/themes/base.scss | 21 + src/scss/thumbnail.scss | 7 +- src/scss/z-index.scss | 8 + src/utils/action.ts | 26 + src/utils/animation.ts | 18 + src/utils/ptz.ts | 2 +- src/utils/substream.ts | 44 -- src/view/substream.ts | 22 + .../actions/actions/call-end.test.ts | 18 + .../actions/actions/call-start.test.ts | 38 + .../actions/actions/substream-off.test.ts | 4 +- .../actions/actions/substream-on.test.ts | 106 ++- .../actions/actions/substream-select.test.ts | 10 +- tests/card-controller/actions/factory.test.ts | 4 + tests/card-controller/call/manager.test.ts | 735 ++++++++++++++++++ tests/card-controller/controller.test.ts | 8 + tests/card-controller/lock/manager.test.ts | 50 +- .../microphone-manager.test.ts | 76 -- .../query-string-manager.test.ts | 6 +- .../status-bar-item-manager.test.ts | 2 + .../view/modifiers/substream-off.test.ts | 20 - .../view/modifiers/substream-on.test.ts | 119 --- .../view/modifiers/substream-select.test.ts | 19 - .../view/modifiers/substream.test.ts | 55 ++ .../card-controller/view/view-manager.test.ts | 61 ++ tests/components-lib/auto-hide.test.ts | 30 + .../microphone-actions-controller.test.ts | 75 ++ .../media-actions-controller.test.ts | 211 ++++- .../menu-button-controller.test.ts | 206 ++++- tests/components-lib/menu-controller.test.ts | 33 + .../status-bar-controller.test.ts | 31 + tests/conditions/conditions-manager.test.ts | 101 +-- tests/config/management.test.ts | 205 +++++ tests/config/profiles/casting.test.ts | 1 - tests/config/types.test.ts | 25 +- tests/test-utils.ts | 2 + tests/utils/action.test.ts | 42 + tests/utils/animation.test.ts | 37 + tests/utils/substream.test.ts | 131 ---- tests/view/substream.test.ts | 66 ++ 116 files changed, 3871 insertions(+), 845 deletions(-) create mode 100644 docs/images/call-sequence.svg create mode 100644 docs/uml/call-sequence.puml create mode 100644 src/card-controller/actions/actions/call-end.ts create mode 100644 src/card-controller/actions/actions/call-start.ts create mode 100644 src/card-controller/call/manager.ts create mode 100644 src/card-controller/call/types.ts rename src/card-controller/lock/{microphone-policy.ts => call-policy.ts} (66%) delete mode 100644 src/card-controller/view/modifiers/substream-off.ts delete mode 100644 src/card-controller/view/modifiers/substream-on.ts delete mode 100644 src/card-controller/view/modifiers/substream-select.ts create mode 100644 src/card-controller/view/modifiers/substream.ts create mode 100644 src/components-lib/auto-hide.ts create mode 100644 src/components/call-controls.ts create mode 100644 src/config/schema/actions/custom/call-end.ts create mode 100644 src/config/schema/actions/custom/call-start.ts create mode 100644 src/config/schema/common/auto-hide.ts create mode 100644 src/config/schema/conditions/custom/call.ts create mode 100644 src/scss/call-controls.scss create mode 100644 src/scss/pop-animation.scss create mode 100644 src/utils/animation.ts delete mode 100644 src/utils/substream.ts create mode 100644 src/view/substream.ts create mode 100644 tests/card-controller/actions/actions/call-end.test.ts create mode 100644 tests/card-controller/actions/actions/call-start.test.ts create mode 100644 tests/card-controller/call/manager.test.ts delete mode 100644 tests/card-controller/view/modifiers/substream-off.test.ts delete mode 100644 tests/card-controller/view/modifiers/substream-on.test.ts delete mode 100644 tests/card-controller/view/modifiers/substream-select.test.ts create mode 100644 tests/card-controller/view/modifiers/substream.test.ts create mode 100644 tests/components-lib/auto-hide.test.ts create mode 100644 tests/utils/animation.test.ts delete mode 100644 tests/utils/substream.test.ts create mode 100644 tests/view/substream.test.ts diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index bdd04b2f..3a910f98 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -12,6 +12,34 @@ action: custom:advanced-camera-card-action | `action` | Must be `custom:advanced-camera-card-action`. | | `advanced_camera_card_action` | A supported Advanced Camera Card action. One of the below actions. | +## `call_end` + +End the [two-way audio](../../../usage/2-way-audio.md) call in progress. Has no effect if no call is active. + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: call_end +``` + +## `call_start` + +Start a [two-way audio](../../../usage/2-way-audio.md) call. + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: call_start +# [...] +``` + +| Parameter | Description | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `action` | Must be `custom:advanced-camera-card-action`. | +| `advanced_camera_card_action` | Must be `call_start`. | +| `camera` | An optional [camera ID](../../cameras/README.md?id=cameras) to make the call from. If omitted, the selected camera is used. | +| `stream` | An optional [camera ID](../../cameras/README.md?id=cameras) of a 2-way-audio [dependency](../../cameras/README.md?id=dependencies) of `camera`. If omitted, the first eligible stream is used. | + +The call starts only if a 2-way-audio-capable stream can be resolved and the browser grants microphone access; otherwise a notification explains why. While the call is in progress an on-screen overlay is shown and (by default) disruptive actions are locked -- see [`live.controls.call`](../../live.md?id=call). + ## `camera_select` Select a given camera. @@ -683,6 +711,19 @@ advanced_camera_card_action: unmute ```yaml elements: + - type: custom:advanced-camera-card-menu-icon + icon: mdi:phone + title: Start a two-way audio call + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: call_start + camera: camera.front_door + - type: custom:advanced-camera-card-menu-icon + icon: mdi:phone-hangup + title: End a two-way audio call + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: call_end - type: custom:advanced-camera-card-menu-icon icon: mdi:alpha-a-circle title: Select Front Door diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index cf855bc2..9304ebd3 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -29,6 +29,21 @@ conditions: | `condition` | Must be `and`. | | `conditions` | A list of other conditions _all_ of which must evaluate `true` in order for this condition to evaluate `true`. | +## `call` + +Matches based on whether a [two-way audio](../usage/2-way-audio.md) call is in progress. + +```yaml +conditions: + - condition: call + # [...] +``` + +| Parameter | Description | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `condition` | Must be `call`. | +| `call` | If `true` (the default) or `false`, the condition is satisfied when a two-way audio call is or is not in progress respectively. | + ## `camera` Matches based on the selected camera. Does not match other cameras (whether @@ -197,14 +212,10 @@ conditions: # [...] ``` -| Parameter | Description | -| ----------- | ---------------------------------------------------------------------------------------------------------------------- | -| `condition` | Must be `microphone`. | -| `connected` | Optional: If `true` or `false` the condition is satisfied if the microphone is connected or disconnected respectively. | -| `muted` | Optional: If `true` or `false` the condition is satisfied if the microphone is muted or unmuted respectively. | - -When multiple parameters are specified they must all match for the condition to -match. +| Parameter | Description | +| ----------- | ------------------------------------------------------------------------------------------------------ | +| `condition` | Must be `microphone`. | +| `muted` | If `true` or `false`, the condition is satisfied when the microphone is muted or unmuted respectively. | ## `not` @@ -391,12 +402,14 @@ conditions: ```yaml conditions: - - condition: camera - cameras: - - camera.office + - condition: call + call: true + - condition: camera + cameras: + - camera.office - condition: config paths: - - "menu.style" + - 'menu.style' - condition: display_mode display_mode: single - condition: expand @@ -416,7 +429,6 @@ conditions: - condition: media_loaded media_loaded: true - condition: microphone - connected: true muted: true - condition: numeric_state entity: sensor.office_temperature @@ -435,8 +447,8 @@ conditions: users: - 581fca7fdc014b8b894519cc531f9a04 - condition: user_agent - user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" - user_agent_re: "Chrome/" + user_agent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' + user_agent_re: 'Chrome/' casting: true companion: true - condition: view diff --git a/docs/configuration/live.md b/docs/configuration/live.md index 01d6ab98..456820a1 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -7,23 +7,23 @@ live: # [...] ``` -| Option | Default | Description | -| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `actions` | | [Actions](actions/README.md) to use for the `live` view. | -| `auto_mute` | `[unselected, hidden, microphone]` | A list of conditions in which live camera feeds are muted. `unselected` will automatically mute when a camera is unselected in the carousel or grid. `hidden` will automatically mute when the camera becomes hidden (e.g. browser tab change) or `microphone` will automatically mute after the microphone is muted as long as the camera stays selected (see the `live.microphone.mute_after_microphone_mute_seconds` to control how long after). Use an empty list (`[]`) to never automatically mute. 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_pause` | `[]` | A list of conditions in which live camera feeds are automatically paused. `unselected` will automatically pause when a camera is unselected in the carousel or grid. `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause. **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 is used. | -| `auto_play` | `[selected, visible]` | A list of conditions in which live camera feeds are automatically played. `selected` will automatically play when a camera is selected in a carousel or grid. `visible` will automatically play when a camera becomes visible (e.g. browser tab change, or visible in a grid but not selected). Use an empty list (`[]`) to never automatically play. 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 parameter on play-after-pause. | -| `auto_unmute` | `[microphone]` | A list of conditions in which live camera feeds are unmuted. `selected` will automatically unmute when a camera is selected in a carousel or grid. `visible` will automatically unmute when a camera becomes visible (e.g. a browser/tab change, or visible in a grid but not selected). `microphone` will automatically unmute after the microphone is unmuted. Use an empty list (`[]`) to never automatically unmute. 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. | -| `controls` | | Configuration for the `live` view controls. See [`controls`](#controls). | -| `display` | | Controls whether to show a single or grid `live` view. See [`display`](#display). | -| `draggable` | `true` | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. | -| `lazy_load` | `true` | Whether or not to lazily load cameras in the camera carousel. Setting this to `false` will cause all cameras to load simultaneously when the `live` carousel is opened (or cause all cameras to load continually if `preload` is also `true`). This will result in a smoother carousel experience at a cost of (potentially) a substantial amount of continually streamed data. | -| `lazy_unload` | `[]` | A list of conditions in which live camera feeds are unloaded. `unselected` will unload a camera when it is not visible in the carousel/grid and `hidden` will unload a camera when the browser itself is minimized or the browser tab changes. An empty list (`[]`, the default) will never automatically unload a stream once loaded unless the user navigates away entirely, so that it's always instantly visible on carousel scroll. Once unloaded, subsequently revisiting the camera will cause a reloading delay. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting. | -| `microphone` | | See [`microphone`](#microphone). | -| `preload` | `false` | 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. The currently-selected camera's media is loaded in the background; other cameras follow the `lazy_load` setting (set `lazy_load: false` to preload them all). This consumes additional network/CPU resources continually. | -| `show_image_during_load` | `true` | 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. | -| `transition_effect` | `slide` | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. | -| `zoomable` | `true` | Whether or not the live carousel can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. | +| Option | Default | Description | +| ------------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `actions` | | [Actions](actions/README.md) to use for the `live` view. | +| `auto_mute` | `[unselected, hidden, microphone, call]` | A list of conditions in which live camera feeds are muted. `unselected` will automatically mute when a camera is unselected in the carousel or grid. `hidden` will automatically mute when the camera becomes hidden (e.g. browser tab change). `microphone` will automatically mute after the microphone is muted as long as the camera stays selected (see the `live.microphone.mute_after_microphone_mute_seconds` to control how long after). `call` will automatically mute the inbound audio when a [two-way audio](../usage/2-way-audio.md) call ends. Use an empty list (`[]`) to never automatically mute. 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_pause` | `[]` | A list of conditions in which live camera feeds are automatically paused. `unselected` will automatically pause when a camera is unselected in the carousel or grid. `hidden` will automatically pause when the browser/tab becomes hidden. Use an empty list (`[]`) to never automatically pause. **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 is used. | +| `auto_play` | `[selected, visible]` | A list of conditions in which live camera feeds are automatically played. `selected` will automatically play when a camera is selected in a carousel or grid. `visible` will automatically play when a camera becomes visible (e.g. browser tab change, or visible in a grid but not selected). Use an empty list (`[]`) to never automatically play. 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 parameter on play-after-pause. | +| `auto_unmute` | `[microphone, call]` | A list of conditions in which live camera feeds are unmuted. `selected` will automatically unmute when a camera is selected in a carousel or grid. `visible` will automatically unmute when a camera becomes visible (e.g. a browser/tab change, or visible in a grid but not selected). `microphone` will automatically unmute after the microphone is unmuted. `call` will automatically unmute the inbound audio when a [two-way audio](../usage/2-way-audio.md) call starts, so the caller can be heard immediately. Use an empty list (`[]`) to never automatically unmute. 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. | +| `controls` | | Configuration for the `live` view controls. See [`controls`](#controls). | +| `display` | | Controls whether to show a single or grid `live` view. See [`display`](#display). | +| `draggable` | `true` | Whether or not the live carousel can be dragged left or right, via touch/swipe and mouse dragging. | +| `lazy_load` | `true` | Whether or not to lazily load cameras in the camera carousel. Setting this to `false` will cause all cameras to load simultaneously when the `live` carousel is opened (or cause all cameras to load continually if `preload` is also `true`). This will result in a smoother carousel experience at a cost of (potentially) a substantial amount of continually streamed data. | +| `lazy_unload` | `[]` | A list of conditions in which live camera feeds are unloaded. `unselected` will unload a camera when it is not visible in the carousel/grid and `hidden` will unload a camera when the browser itself is minimized or the browser tab changes. An empty list (`[]`, the default) will never automatically unload a stream once loaded unless the user navigates away entirely, so that it's always instantly visible on carousel scroll. Once unloaded, subsequently revisiting the camera will cause a reloading delay. Some live providers (e.g. `webrtc-card`) implement their own lazy unloading independently which may occur regardless of the value of this setting. | +| `microphone` | | See [`microphone`](#microphone). | +| `preload` | `false` | 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. The currently-selected camera's media is loaded in the background; other cameras follow the `lazy_load` setting (set `lazy_load: false` to preload them all). This consumes additional network/CPU resources continually. | +| `show_image_during_load` | `true` | 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. | +| `transition_effect` | `slide` | Effect to apply as a transition between live cameras. Accepted values: `slide` or `none`. | +| `zoomable` | `true` | Whether or not the live carousel can be zoomed and panned, via touch/pinch and mouse scroll wheel with `ctrl` held. | ## `controls` @@ -38,11 +38,28 @@ live: | Option | Default | Description | | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------ | | `builtin` | `true` | Whether to show the built in (browser) video controls on live video. | +| `call` | | Configures the on-screen [two-way audio](../usage/2-way-audio.md) call controls. See [`call`](#call). | | `next_previous` | | Configures how the "Next & Previous" controls are shown on the `live` view. See [`next_previous`](#next_previous). | | `thumbnails` | | Configures how thumbnails are shown on the `live` view. See [`thumbnails`](#thumbnails). | | `timeline` | | Configures how the mini-timeline is shown on the `live` view. See [`timeline`](#timeline). | | `wheel` | `true` | Whether to allow mouse wheel to scroll through the carousel. | +### `call` + +Configures the on-screen controls shown during a [two-way audio](../usage/2-way-audio.md) call. The overlay appears whenever a call is in progress and offers buttons to mute/unmute the microphone, mute/unmute the inbound audio, and end the call. + +```yaml +live: + controls: + call: + # [...] +``` + +| Option | Default | Description | +| ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `button_size` | `40` | The size of the call control buttons in pixels. Must be >= `20`. | +| `lock` | `true` | Whether to lock the rest of the card controls/actions while a call is in progress. Prevents an accidental tap, swipe or action mid-call. Set to `false` to allow interactions regardless of call state. | + ### `next_previous` Configures how the "Next & Previous" controls are shown on the live view. @@ -54,10 +71,11 @@ live: # [...] ``` -| Option | Default | Description | -| ------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `size` | `48` | The size of the next/previous controls in pixels. Must be >= `20`. | -| `style` | `chevrons` | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . | +| Option | Default | Description | +| ----------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto_hide` | `[call, casting]` | The conditions under which the next/previous controls auto-hide: a list of zero or more of `call` (a [two-way audio](../usage/2-way-audio.md) call is active) and `casting` (the card is being cast). Set to `[]` to disable. | +| `size` | `48` | The size of the next/previous controls in pixels. Must be >= `20`. | +| `style` | `chevrons` | When viewing live cameras, what kind of controls to show to move to the previous/next camera. Acceptable values: `chevrons`, `icons`, `none` . | ### `ptz` @@ -177,14 +195,13 @@ live: microphone: ``` -| Option | Default | Description | -| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `always_connected` | `false` | Whether or not to keep the microphone stream continually connected while the card is running, or only when microphone is used (default). In the latter case there'll be a connection reset when the microphone is first used -- using this option can avoid that reset. | -| `auto_mute` | `[]` | A list of conditions in which the microphone is muted. `unselected` will automatically mute the microphone when a camera is unselected in the carousel or grid. `hidden` will automatically mute the microphone when the card becomes hidden (e.g. browser/tab change). Use an empty list (`[]`, the default) to never automatically mute the microphone via these conditions. | -| `auto_unmute` | `[]` | A list of conditions in which the microphone is unmuted. `selected` will automatically unmute the microphone when a camera is selected in the carousel or grid (useful for an always-hot mic on the currently selected camera). `visible` will automatically unmute when the card becomes visible. Use an empty list (`[]`, the default) to never automatically unmute the microphone via these conditions. The browser will still prompt for microphone permission on first unmute. | -| `disconnect_seconds` | `90` | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`. | -| `lock` | `true` | Whether to lock disruptive actions (view/camera/substream changes, pause, reload, casting) while the microphone is unmuted. Prevents an accidental tap, swipe, or button press from cutting off the session mid-sentence during 2-way audio. Set to `false` to allow all actions regardless of microphone state. | -| `mute_after_microphone_mute_seconds` | `60` | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`. | +| Option | Default | Description | +| ------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `always_connected` | `false` | Whether or not to keep the microphone stream continually connected while the card is running, or only connect it when first needed (default) -- typically when a [two-way audio](../usage/2-way-audio.md) call is started. In the latter case there'll be a connection reset the first time the microphone connects -- using this option can avoid that reset. | +| `auto_mute` | `[call]` | A list of conditions in which the microphone is muted. `unselected` will automatically mute the microphone when a camera is unselected in the carousel or grid. `hidden` will automatically mute the microphone when the card becomes hidden (e.g. browser/tab change). `call` will automatically mute the microphone when a [two-way audio](../usage/2-way-audio.md) call ends. Use an empty list (`[]`) to never automatically mute the microphone via these conditions. | +| `auto_unmute` | `[]` | A list of conditions in which the microphone is unmuted. `selected` will automatically unmute the microphone when a camera is selected in the carousel or grid. `visible` will automatically unmute when the card becomes visible. `call` will automatically unmute the microphone when a [two-way audio](../usage/2-way-audio.md) call starts. By default this list is empty, so the microphone stays muted when a call starts (push-to-talk) -- tap the microphone button in the call overlay to talk. The microphone is still connected when the call starts (just left muted), so the browser may prompt for microphone permission at that point. | +| `disconnect_seconds` | `90` | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`. | +| `mute_after_microphone_mute_seconds` | `60` | The number of seconds after the microphone mutes to automatically mute the inbound audio when `live.auto_mute` includes `microphone`. | See [Using 2-way audio](../usage/2-way-audio.md) for more information about the very particular requirements that must be followed for 2-way audio to work. @@ -201,8 +218,11 @@ live: auto_mute: - unselected - hidden + - microphone + - call auto_unmute: - microphone + - call preload: false lazy_load: true lazy_unload: [] @@ -211,7 +231,13 @@ live: transition_effect: slide controls: builtin: true + call: + button_size: 40 + lock: true next_previous: + auto_hide: + - call + - casting style: chevrons size: 48 wheel: true @@ -247,10 +273,10 @@ live: 24h: true microphone: always_connected: false - auto_mute: [] + auto_mute: + - call auto_unmute: [] disconnect_seconds: 90 - lock: true mute_after_microphone_mute_seconds: 60 display: mode: single diff --git a/docs/configuration/media-viewer.md b/docs/configuration/media-viewer.md index 43ab86b0..f57c4d0a 100644 --- a/docs/configuration/media-viewer.md +++ b/docs/configuration/media-viewer.md @@ -50,10 +50,11 @@ media_viewer: # [...] ``` -| Option | Default | Description | -| ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `size` | `48` | The size of the next/previous controls in pixels. Must be >= `20`. | -| `style` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | +| Option | Default | Description | +| ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto_hide` | `[casting]` | The conditions under which the next/previous controls auto-hide. The only condition is `casting` (the card is being cast). Set to `[]` to disable. | +| `size` | `48` | The size of the next/previous controls in pixels. Must be >= `20`. | +| `style` | `thumbnails` | When viewing media, what kind of controls to show to move to the previous/next media item. Acceptable values: `thumbnails`, `chevrons`, `none` . | ### `ptz` @@ -183,6 +184,8 @@ media_viewer: controls: builtin: true next_previous: + auto_hide: + - casting size: 48 style: thumbnails wheel: true diff --git a/docs/configuration/menu.md b/docs/configuration/menu.md index a311a4b5..b8e5b17c 100644 --- a/docs/configuration/menu.md +++ b/docs/configuration/menu.md @@ -7,13 +7,14 @@ menu: # [...] ``` -| Option | Default | Description | -| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `alignment` | `left` | 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` | The size of the menu buttons in pixels. Must be >= `20`. | -| `buttons` | | Whether to show or hide built-in buttons. See [`buttons`](#buttons). | -| `position` | `top` | 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. | -| `style` | `hidden` | The menu style to show by default, one of `none`, `hidden`, `hover`, `hover-card`, `overlay`, or `outside`. See [`style`](#style). | +| Option | Default | Description | +| ------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `alignment` | `left` | 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`). | +| `auto_hide` | `[call, casting]` | The conditions under which the menu auto-hides. A list of zero or more of `call` (a [two-way audio](../usage/2-way-audio.md) call is active) and `casting` (the card is being cast, e.g. to a Chromecast). Set to `[]` to disable. | +| `button_size` | `40` | The size of the menu buttons in pixels. Must be >= `20`. | +| `buttons` | | Whether to show or hide built-in buttons. See [`buttons`](#buttons). | +| `position` | `top` | 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. | +| `style` | `hidden` | The menu style to show by default, one of `none`, `hidden`, `hover`, `hover-card`, `overlay`, or `outside`. See [`style`](#style). | ## `buttons` @@ -28,6 +29,7 @@ menu: | Button name | Description | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `call` | The `call` menu button: starts or ends a [two-way audio](../usage/2-way-audio.md) call. | | `camera_ui` | 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). | | `cameras` | The camera selection submenu. Will only appear if multiple cameras are configured. | | `clips` | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. | @@ -42,7 +44,7 @@ menu: | `iris` | The main Advanced Camera Card `iris` menu button: brings the user to the default configured view (`view.default`), or collapses/expands the menu if the `menu.style` is `hidden` . | | `live` | The `live` view menu button: brings the user to the `live` view. | | `media_player` | 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. | -| `microphone` | The `microphone` button allows usage of 2-way audio in certain configurations. See [Using 2-way audio](../usage/2-way-audio.md). | +| `microphone` | The `microphone` button mutes/unmutes the microphone. It is only shown during an active call, since the microphone only transmits while a call is in progress. See [Using 2-way audio](../usage/2-way-audio.md). | | `mute` | The `mute` button: toggles the mute state of the selected media. | | `pip` | The `pip` menu button: enter Picture-in-Picture mode. Floats the video element as a native browser overlay. | | `play` | The `play` button: toggles the play/pause state of the selected media. | @@ -57,15 +59,15 @@ menu: ### Options for each button -| Option | Default | Description | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. | -| `enabled` | `true` for `camera_ui`, `cameras`, `display_mode`, `download`, `folders`, `fullscreen`, `gallery`, `info`, `iris`, `live`, `media_player`, `set_review`, `substreams` and `timeline`. `false` for `clips`, `expand`, `image`, `microphone`, `mute`, `pip`, `play`, `ptz_controls`, `ptz_home`, `recordings`, `reviews`, `screenshot` and `snapshots`. | Whether or not to show the button. | -| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). | -| `inert` | `false` | If `true` the button is shown but rendered as inert (greyed out, non-interactive). Differs from `enabled: false`, which removes the button entirely. | -| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). | -| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. | -| `state_color` | `true` | Whether to colorize the button based on the state of a related entity (where applicable). | +| Option | Default | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `alignment` | `matching` | Whether this menu item should have an alignment that is `matching` the menu alignment or `opposing` the menu alignment. Can be used to create two separate groups of buttons on the menu. The `priority` option orders buttons within a given `alignment`. | +| `enabled` | `true` for `call`, `camera_ui`, `cameras`, `display_mode`, `download`, `folders`, `fullscreen`, `gallery`, `info`, `iris`, `live`, `media_player`, `set_review`, `substreams` and `timeline`. `false` for `clips`, `expand`, `image`, `microphone`, `mute`, `pip`, `play`, `ptz_controls`, `ptz_home`, `recordings`, `reviews`, `screenshot` and `snapshots`. | Whether or not to show the button. | +| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. See also [custom icons](../usage/custom-icons.md). | +| `inert` | `false` | If `true` the button is shown but rendered as inert (greyed out, non-interactive). Differs from `enabled: false`, which removes the button entirely. | +| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). | +| `priority` | `50` | The menu item priority. Higher priority items are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`). Priority applies separately to `matching` and `opposing` groups (see `alignment` above). Minimum `0`, maximum `100`. | +| `state_color` | `true` | Whether to colorize the button based on the state of a related entity (where applicable). | ### Additional options: `microphone` @@ -100,7 +102,16 @@ This card supports several menu styles. ```yaml menu: alignment: left + auto_hide: + - call + - casting buttons: + call: + priority: 50 + enabled: true + inert: false + alignment: matching + icon: mdi:phone camera_ui: priority: 50 enabled: true diff --git a/docs/configuration/status-bar.md b/docs/configuration/status-bar.md index 5f8f1654..e3af3452 100644 --- a/docs/configuration/status-bar.md +++ b/docs/configuration/status-bar.md @@ -7,13 +7,14 @@ status_bar: # [...] ``` -| Option | Default | Description | -| --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `position` | `bottom` | Whether to place the status bar at the `top` or `bottom` of the card. | -| `popup_seconds` | `3` | The number of seconds to display the status bar when using the `popup` style. | -| `height` | `40` | The height of the status bar in pixels. | -| `items` | | Whether to show or hide built-in status bar items. See [`items`](#items). | -| `style` | `popup` | The status bar style to show by default, one of `none`, `hover`, `hover-card`, `overlay`, `outside` or `popup`. See [`style`](#style). | +| Option | Default | Description | +| --------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto_hide` | `[call, casting]` | The conditions under which the status bar auto-hides. A list of zero or more of `call` (a [two-way audio](../usage/2-way-audio.md) call is active) and `casting` (the card is being cast, e.g. to a Chromecast). Set to `[]` to disable. | +| `position` | `bottom` | Whether to place the status bar at the `top` or `bottom` of the card. | +| `popup_seconds` | `3` | The number of seconds to display the status bar when using the `popup` style. | +| `height` | `40` | The height of the status bar in pixels. | +| `items` | | Whether to show or hide built-in status bar items. See [`items`](#items). | +| `style` | `popup` | The status bar style to show by default, one of `none`, `hover`, `hover-card`, `overlay`, `outside` or `popup`. See [`style`](#style). | ## `items` @@ -73,6 +74,9 @@ This card supports several menu styles. ```yaml status_bar: + auto_hide: + - call + - casting position: bottom popup_seconds: 3 height: 40 diff --git a/docs/images/call-sequence.svg b/docs/images/call-sequence.svg new file mode 100644 index 00000000..43d20788 --- /dev/null +++ b/docs/images/call-sequence.svg @@ -0,0 +1 @@ +UserCallMicrophoneGuestUserUserCallCallMicrophoneMicrophoneGuestGuestopt[live.microphone.always_connectedis enabled]Microphone connection attempted (muted)Connected at card load, before (and independent of) any callMicrophone access grantedStart a callVia Call menu button orcall_startaction.Needs a camera that supports 2-way audioopt[live.microphone.always_connectednot enabled]Microphone connection attempted (muted)Stays muted by default (live.microphone.auto_unmute)Microphone access grantedThe microphone must connect for the call to proceed —if access is denied or unsupported, the call does not startYou hear the callerInbound audio unmutes if configured (live.auto_unmute)Menu, status bar & next/previous hide (auto_hide)Camera navigation is locked (live.controls.call.lock)Unmute to talkList 'call' inlive.microphone.auto_unmuteto unmute automaticallyloop[Until the call ends]TalkingRespondingEnd the callVia Hang-up button,call_endaction, or — whenlive.controls.call.lockis disabled — navigating awayInbound audio mutes (live.auto_mute)Microphone mutes (live.microphone.auto_mute)Disconnects afterlive.microphone.disconnect_secondsunlesslive.microphone.always_connected \ No newline at end of file diff --git a/docs/uml/call-sequence.puml b/docs/uml/call-sequence.puml new file mode 100644 index 00000000..8dffe2f0 --- /dev/null +++ b/docs/uml/call-sequence.puml @@ -0,0 +1,59 @@ +@startuml +skinparam backgroundColor white + +actor User +participant "Call" as Card +participant Microphone as Mic +actor Guest + +opt **""live.microphone.always_connected""** is enabled + Card -> Mic : Microphone connection attempted (muted) + note over Mic : Connected at card load, before (and independent of) any call + Mic --> User : Microphone access granted +end + +User -> Card : Start a call +note over User, Card + Via Call menu button or **""call_start""** action. + Needs a camera that supports 2-way audio +end note + +opt **""live.microphone.always_connected""** not enabled + Card -> Mic : Microphone connection attempted (muted) + note over Mic : Stays muted by default (**""live.microphone.auto_unmute""**) + Mic --> User : Microphone access granted +end + +note over User, Mic + The microphone must connect for the call to proceed — + if access is denied or unsupported, the call does not start +end note + +Card --> User : You hear the caller +note over Card + Inbound audio unmutes if configured (**""live.auto_unmute""**) + Menu, status bar & next/previous hide (**""auto_hide""**) + Camera navigation is locked (**""live.controls.call.lock""**) +end note + +User -> Mic : Unmute to talk +note over Mic : List 'call' in **""live.microphone.auto_unmute""** to unmute automatically + +loop Until the call ends + User -[#red]> Guest : Talking + Guest -[#red]-> User : Responding +end + +User -> Card : End the call +note over User, Card + Via Hang-up button, **""call_end""** action, or — when + **""live.controls.call.lock""** is disabled — navigating away +end note + +Card --> User : Inbound audio mutes (**""live.auto_mute""**) +Card -> Mic : Microphone mutes (**""live.microphone.auto_mute""**) +note over Mic + Disconnects after **""live.microphone.disconnect_seconds""** + unless **""live.microphone.always_connected""** +end note +@enduml diff --git a/docs/usage/2-way-audio.md b/docs/usage/2-way-audio.md index 425fa35b..034c5e6a 100644 --- a/docs/usage/2-way-audio.md +++ b/docs/usage/2-way-audio.md @@ -17,8 +17,7 @@ challenging. - Only Frigate cameras are supported. - Only the `go2rtc` live provider is supported. -- Only the `webrtc` mode supports 2-way audio: -- Must have microphone menu button enabled: +- Only the `webrtc` mode supports 2-way audio. If your setup supports 2-way audio but detection is intermittent on load: @@ -37,24 +36,71 @@ cameras: - webrtc # Optional: For slower cameras increase timeout (default: 2) metadata_fetch_timeout_seconds: 10 -menu: - buttons: - microphone: - enabled: true ``` ## Usage +Two-way audio is driven by the **call** menu button (a phone icon). It is +enabled by default and appears in the `live` view whenever the selected camera +-- or one of its [dependencies](../configuration/cameras/README.md?id=dependencies) +-- supports 2-way audio. + +- Tap the call button to start a call. An on-screen overlay appears with + controls to mute/unmute the microphone, mute/unmute the inbound audio, and end + the call. When more than one 2-way-audio camera is available the button + becomes a submenu with one entry per camera. +- When a call starts the inbound audio is unmuted automatically, so the caller + can be heard immediately. The microphone stays muted by default + (push-to-talk) -- tap the microphone button in the overlay to speak. This is + configurable via [`live.microphone.auto_unmute`](../configuration/live.md?id=microphone) + and [`live.auto_unmute`](../configuration/live.md). - The camera will always load _without_ the microphone connected, unless the - [`always_connected`](../configuration/live.md?id=microphone) microphone option is - set to `true`. -- To speak, hold-down the microphone menu button. - - On first press, this will reset the `webrtc` connection to include 2-way - audio unless [`always_connected`](../configuration/live.md?id=microphone) has - been used. - - Thereafter hold the microphone button down to unmute/speak, let go to - mute. -- The video will automatically reset to remove the microphone after the number - of seconds specified by - [`disconnect_seconds`](../configuration/live.md?id=microphone) configuration have - elapsed since the last mute/unmute press. + [`always_connected`](../configuration/live.md?id=microphone) microphone option + is set to `true`. On the first call there may be a brief `webrtc` connection + reset to include 2-way audio. +- While a call is in progress the card locks disruptive actions (camera and + substream changes, casting, reload, etc.) so an accidental tap, swipe, or + button press doesn't cut the call off. Set + [`live.controls.call.lock`](../configuration/live.md?id=call) to `false` to + disable this. +- End the call with the overlay's end-call button. When the call ends the + microphone and inbound audio are muted again. +- The video automatically resets to remove the microphone after the number of + seconds specified by [`disconnect_seconds`](../configuration/live.md?id=microphone) + have elapsed since the call ended. + +Calls can also be started and ended programmatically with the +[`call_start`](../configuration/actions/custom/README.md?id=call_start) and +[`call_end`](../configuration/actions/custom/README.md?id=call_end) actions -- +for example, from an [automation](../configuration/automations.md) that fires +when a doorbell sensor triggers. The [`call` condition](../configuration/conditions.md?id=call) +can be used to show or hide elements while a call is in progress. + +### Call lifecycle + +The diagram below traces a call from start to finish: + +![Call lifecycle sequence diagram](../images/call-sequence.svg ':size=600') + +## Talking with a single tap + +By default, two taps are needed to speak: the call button starts the call (so +you can hear), then the microphone button in the call overlay unmutes your +microphone (so you can be heard). This push-to-talk default keeps the microphone +muted until you explicitly choose to speak. + +To collapse that to a single tap, unmute the microphone automatically when a +call starts: + +```yaml +live: + microphone: + auto_unmute: ['call'] +``` + +The call button then behaves as a toggle -- one tap starts the call and opens +the microphone, a second tap ends the call and closes it again. Note this also +opens the microphone for calls started by an +[automation](../configuration/automations.md); leave +[`auto_unmute`](../configuration/live.md?id=microphone) empty (the default) to +always start muted. diff --git a/package.json b/package.json index 240354b2..5e2de898 100644 --- a/package.json +++ b/package.json @@ -185,6 +185,7 @@ "docs-check-links": "docker run --init --rm -v \"$(pwd):/input\" -w /input lycheeverse/lychee --config lychee.toml \"./docs/\"", "docs-update-images": "./scripts/docs-update-images.sh", "docs-update-dependencies": "./scripts/docs-update-dependencies.sh", + "docs-update-uml": "docker run --init --rm -u \"$(id -u):$(id -g)\" -e HOME=/tmp -e JAVA_TOOL_OPTIONS=-Duser.home=/tmp -v \"$(pwd):/data\" -w /data plantuml/plantuml -tsvg docs/uml/call-sequence.puml -o /data/docs/images", "lint": "eslint '{src,tests}/**/*.ts'", "format": "prettier --write .", "format-check": "prettier --check .", diff --git a/src/camera-manager/store.ts b/src/camera-manager/store.ts index eaccb4f8..3a9a471c 100644 --- a/src/camera-manager/store.ts +++ b/src/camera-manager/store.ts @@ -180,11 +180,19 @@ export class CameraManagerStore implements CameraManagerReadOnlyConfigStore { } /** - * Get all cameras that depend on a given camera. - * @param cameraManager The camera manager. + * Get all cameras that depend on a given camera, optionally filtered by + * capability. + * + * Iteration order (guaranteed by Set insertion order): if `cameraID` itself + * passes the capability filter (or no filter is supplied), it is the first + * element of the returned set. Callers may rely on this ordering to pick a + * sensible default (e.g. "prefer the parent when eligible, otherwise the + * first matching dependency"). + * * @param cameraID ID of the target camera. - * @returns A set of dependent cameraIDs or null (since JS sets guarantee order, - * the first item in the set is guaranteed to be the cameraID itself). + * @param capabilitySearchKeys Optional capability filter. + * @param options Optional search options. + * @returns A set of dependent cameraIDs. */ public getAllDependentCameras( cameraID: string, diff --git a/src/card-controller/actions/actions/call-end.ts b/src/card-controller/actions/actions/call-end.ts new file mode 100644 index 00000000..954b71ad --- /dev/null +++ b/src/card-controller/actions/actions/call-end.ts @@ -0,0 +1,11 @@ +import { CallEndActionConfig } from '../../../config/schema/actions/custom/call-end'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class CallEndAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + api.getCallManager().end(); + } +} diff --git a/src/card-controller/actions/actions/call-start.ts b/src/card-controller/actions/actions/call-start.ts new file mode 100644 index 00000000..11751015 --- /dev/null +++ b/src/card-controller/actions/actions/call-start.ts @@ -0,0 +1,11 @@ +import { CallStartActionConfig } from '../../../config/schema/actions/custom/call-start'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class CallStartAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + + await api.getCallManager().start(this._action.camera, this._action.stream); + } +} diff --git a/src/card-controller/actions/actions/media-player.ts b/src/card-controller/actions/actions/media-player.ts index b3703cd4..d34e0cdd 100644 --- a/src/card-controller/actions/actions/media-player.ts +++ b/src/card-controller/actions/actions/media-player.ts @@ -1,6 +1,6 @@ import { MediaPlayerActionConfig } from '../../../config/schema/actions/custom/media-player'; -import { getStreamCameraID } from '../../../utils/substream'; import { ViewItemClassifier } from '../../../view/item-classifier'; +import { getStreamCameraID } from '../../../view/substream'; import { CardActionsAPI } from '../../types'; import { AdvancedCameraCardAction } from './base'; diff --git a/src/card-controller/actions/actions/substream-off.ts b/src/card-controller/actions/actions/substream-off.ts index f31837ac..7c536950 100644 --- a/src/card-controller/actions/actions/substream-off.ts +++ b/src/card-controller/actions/actions/substream-off.ts @@ -1,6 +1,6 @@ import { GeneralActionConfig } from '../../../config/schema/actions/custom/general'; import { CardActionsAPI } from '../../types'; -import { SubstreamOffViewModifier } from '../../view/modifiers/substream-off'; +import { SubstreamViewModifier } from '../../view/modifiers/substream'; import { AdvancedCameraCardAction } from './base'; export class SubstreamOffAction extends AdvancedCameraCardAction { @@ -8,7 +8,7 @@ export class SubstreamOffAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); + const view = api.getViewManager().getView(); + if (!view) { + return; + } + api.getViewManager().setViewByParameters({ - modifiers: [new SubstreamOnViewModifier(api)], + modifiers: [ + new SubstreamViewModifier( + this._getCycledSubstreamID(view, api.getCameraManager()), + ), + ], }); } + + // The next substream in the selected camera's cycle: its `substream` + // dependencies in order, wrapping back round. `undefined` means the camera's + // own stream (no substream). + private _getCycledSubstreamID( + view: View, + cameraManager: CameraManager, + ): string | undefined { + if (!view.camera) { + return undefined; + } + const dependencies = [ + ...cameraManager.getStore().getAllDependentCameras(view.camera, 'substream'), + ]; + if (dependencies.length <= 1) { + return undefined; + } + const current = view.context?.live?.overrides?.get(view.camera) ?? view.camera; + const currentIndex = dependencies.indexOf(current); + const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length; + // Index 0 is the camera itself, i.e. no substream. + return dependencies[nextIndex] === view.camera ? undefined : dependencies[nextIndex]; + } } diff --git a/src/card-controller/actions/actions/substream-select.ts b/src/card-controller/actions/actions/substream-select.ts index cb41ce82..eacfc19a 100644 --- a/src/card-controller/actions/actions/substream-select.ts +++ b/src/card-controller/actions/actions/substream-select.ts @@ -1,6 +1,6 @@ import { SubstreamSelectActionConfig } from '../../../config/schema/actions/custom/substream-select'; import { CardActionsAPI } from '../../types'; -import { SubstreamSelectViewModifier } from '../../view/modifiers/substream-select'; +import { SubstreamViewModifier } from '../../view/modifiers/substream'; import { AdvancedCameraCardAction } from './base'; export class SubstreamSelectAction extends AdvancedCameraCardAction { @@ -8,7 +8,7 @@ export class SubstreamSelectAction extends AdvancedCameraCardAction { + const view = this._api.getViewManager().getView(); + + const parentID = cameraID ?? view?.camera; + if (!view || !parentID) { + return; + } + + if ( + !this._api + .getCameraManager() + .getStore() + .getCameraIDsWithCapability('live') + .has(parentID) + ) { + this._notifyError('error.call_invalid_target'); + return; + } + + const targetID = streamID + ? this._validateStream(parentID, streamID) + : this._pickDefaultTarget(view, parentID); + if (!targetID) { + return; + } + + // `callCameraID` is the substream carrying the call audio -- absent when + // the call runs on the parent camera itself. + const callCameraID = targetID === parentID ? undefined : targetID; + + const existingCall = this._call; + if ( + existingCall && + existingCall.cameraID === parentID && + existingCall.callCameraID === callCameraID + ) { + // This exact call (same parent camera and stream) is already running; a + // repeat request must not disrupt it. + return; + } + + if (!this._microphonePreflight()) { + return; + } + + if (!(await this._connectMicrophone())) { + return; + } + + // Store the previous view so it can be restored later. A call superseding + // another inherits the earlier call's previous view -- the user never left + // the call. `queryResults` are dropped (re-fetched fresh on restore); + // `context` is deep-cloned so the call engaging its own substream below + // cannot mutate the snapshot. + const previousView = existingCall + ? existingCall.previousView + : view.evolve({ queryResults: null, context: cloneDeep(view.context) }); + + const needsNavigation = !view.is('live') || view.camera !== parentID; + + // Any other call in progress is superseded. Ended here -- after the + // preflight passes -- so a failed preflight leaves the existing call + // intact. + if (existingCall) { + this._end(false); + } + + this._call = { + cameraID: parentID, + ...(callCameraID && { callCameraID }), + previousView, + }; + + this._api.getViewManager().setViewByParameters({ + ...(needsNavigation && { + params: { view: 'live', camera: parentID }, + }), + modifiers: [new SubstreamViewModifier(callCameraID, parentID)], + force: true, + }); + this._api.getConditionStateManager().setState({ call: true }); + } + + // Ends the call and returns to the view that was showing before + // `call_start` -- the user-facing `call_end`. + public end(): void { + this._end(true); + } + + // `restoreView` navigates back to the pre-call view -- the symmetric + // counterpart of `call_start`'s navigation -- for an explicit `call_end`. It + // is `false` for auto-ends (navigating away, camera/substream change), where + // the user has already chosen a destination and the pre-call view is + // deliberately not reinstated; only the manager's own auto-end paths pass it. + private _end(restoreView: boolean): void { + if (!this._call) { + return; + } + const call = this._call; + const previousView = call.previousView; + + // Clear the session first: ending the call dispatches a view change, and + // the resulting condition-state change must not see this (now-ending) call + // and recurse. + this._call = null; + + const viewManager = this._api.getViewManager(); + + // Navigate back only on an explicit end, and only when the call actually + // moved away from where the user was (a call started from its own live + // view has nowhere to return). The previous view's query is re-executed + // so results are fresh. + if ( + restoreView && + (previousView.view !== 'live' || previousView.camera !== call.cameraID) + ) { + viewManager.setViewByParametersWithExistingQuery({ + baseView: previousView, + force: true, + }); + } else { + // Otherwise stay where we are and just undo the call's substream change + // on its own camera, reading the pre-call value. + const previousStream = getStreamCameraID(previousView, call.cameraID); + viewManager.setViewByParameters({ + modifiers: [ + new SubstreamViewModifier( + previousStream && previousStream !== call.cameraID + ? previousStream + : undefined, + call.cameraID, + ), + ], + force: true, + }); + } + this._api.getConditionStateManager().setState({ call: false }); + } + + // End the call once it can no longer be conducted from where it started: the + // view leaves `live` (the call overlay exists only there, so the call would + // otherwise be stranded with no controls), the selected camera changes, or + // the engaged substream moves off the call's audio source. Covers navigation + // and `live_substream_*` actions taken while `live.controls.call.lock` is + // disabled, as well as any forced view change. + private _handleConditionStateChange = (stateChange: ConditionStateChange): void => { + if ( + this._call && + (stateChange.new.view !== 'live' || + stateChange.new.camera !== this._call.cameraID || + stateChange.new.substreamID !== this._call.callCameraID) + ) { + this._end(false); + } + }; + + // ========================================================================= + // Helpers + // ========================================================================= + + private _notifyError(messageKey: string): void { + this._api.getNotificationManager().setNotification( + createNotificationFromText(localize(messageKey), { + heading: { text: localize('error.call_unavailable_heading') }, + }), + ); + } + + // Returns `true` to proceed, `false` to abort (with a notification already + // surfaced). + private _microphonePreflight(): boolean { + const microphoneManager = this._api.getMicrophoneManager(); + + if (!microphoneManager.isSupported()) { + this._notifyError('error.call_microphone_unsupported'); + return false; + } + + if (microphoneManager.isForbidden()) { + this._notifyError('error.call_microphone_forbidden'); + return false; + } + + return true; + } + + private async _connectMicrophone(): Promise { + const microphoneManager = this._api.getMicrophoneManager(); + if (microphoneManager.isConnected()) { + return true; + } + try { + await microphoneManager.connect(); + return true; + } catch { + this._notifyError('error.call_microphone_forbidden'); + return false; + } + } + + private _hasCallCapability(cameraID: string): boolean { + return !!this._api + .getCameraManager() + .getCameraCapabilities(cameraID) + ?.has('2-way-audio'); + } + + // Validate an explicitly-requested call stream: it must be `cameraID` itself + // or one of its 2-way-audio dependencies. + private _validateStream(cameraID: string, streamID: string): string | null { + const eligibleCameraIDs = this._api + .getCameraManager() + .getStore() + .getAllDependentCameras(cameraID, '2-way-audio'); + if (!eligibleCameraIDs.has(streamID)) { + this._notifyError('error.call_invalid_target'); + return null; + } + return streamID; + } + + // Pick the default call target. Prefer the currently-engaged stream when + // it's call-capable (keeps the user's substream selection intact). Else + // fall back to the parent itself (if call-capable) or the first eligible + // dependency. Returns null + notification if neither path finds a target. + private _pickDefaultTarget(view: View, parentID: string): string | null { + const currentStream = getStreamCameraID(view, parentID); + if (currentStream && this._hasCallCapability(currentStream)) { + return currentStream; + } + + const candidates = [ + ...this._api + .getCameraManager() + .getStore() + .getAllDependentCameras(parentID, '2-way-audio'), + ]; + if (!candidates.length) { + this._notifyError('error.call_no_two_way_audio'); + return null; + } + return candidates[0]; + } +} diff --git a/src/card-controller/call/types.ts b/src/card-controller/call/types.ts new file mode 100644 index 00000000..832515c7 --- /dev/null +++ b/src/card-controller/call/types.ts @@ -0,0 +1,15 @@ +import { View } from '../../view/view'; + +export interface CallSession { + // The camera that owns the call. + cameraID: string; + + // The substream carrying the 2-way audio: a 2-way-audio-capable + // substream/dependency of `cameraID`. Absent when the call runs on + // `cameraID`'s own stream. + callCameraID?: string; + + // The view from before the call started: a clone with `queryResults` dropped. + // Used to undo the call when it ends. + previousView: View; +} diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index ff8b118e..ebd91cee 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -10,6 +10,7 @@ import { ResolvedMediaCache } from '../ha/resolved-media'; import { LovelaceCardEditor } from '../ha/types'; import { ActionsManager } from './actions/actions-manager'; import { AutomationsManager } from './automations-manager'; +import { CallManager } from './call/manager'; import { CameraURLManager } from './camera-url-manager'; import { CardElementManager, @@ -26,6 +27,8 @@ import { FullscreenManager } from './fullscreen/fullscreen-manager'; import { HASSManager } from './hass/hass-manager'; import { InitializationManager } from './initialization-manager'; import { InteractionManager } from './interaction-manager'; +import { createIssueManager } from './issues/factory'; +import { IssueManager } from './issues/issue-manager'; import { KeyboardStateManager } from './keyboard-state-manager'; import { LockManager } from './lock/manager'; import { MediaLoadedInfoManager } from './media-info-manager'; @@ -33,8 +36,6 @@ import { MediaPlayerManager } from './media-player-manager'; import { MicrophoneManager } from './microphone-manager'; import { NotificationManager } from './notification-manager'; import { PIPManager } from './pip-manager'; -import { createIssueManager } from './issues/factory'; -import { IssueManager } from './issues/issue-manager'; import { QueryStringManager } from './query-string-manager'; import { StatusBarItemManager } from './status-bar-item-manager'; import { StyleManager } from './style-manager'; @@ -55,6 +56,7 @@ import { CardHASSAPI, CardInitializerAPI, CardInteractionAPI, + CardIssueManagerAPI, CardKeyboardStateAPI, CardLockAPI, CardMediaLoadedAPI, @@ -62,7 +64,6 @@ import { CardMicrophoneAPI, CardNotificationAPI, CardPIPAPI, - CardIssueManagerAPI, CardQueryStringAPI, CardStyleAPI, CardTriggersAPI, @@ -112,6 +113,7 @@ export class CardController private _actionsManager = new ActionsManager(this, new TemplateRenderer()); private _automationsManager = new AutomationsManager(this); + private _callManager = new CallManager(this); private _cameraManager = new CameraManager(this); private _cameraURLManager = new CameraURLManager(this); private _cardElementManager: CardElementManager; @@ -166,6 +168,10 @@ export class CardController return this._automationsManager; } + public getCallManager(): CallManager { + return this._callManager; + } + public getCameraManager(): CameraManager { return this._cameraManager; } diff --git a/src/card-controller/lock/microphone-policy.ts b/src/card-controller/lock/call-policy.ts similarity index 66% rename from src/card-controller/lock/microphone-policy.ts rename to src/card-controller/lock/call-policy.ts index 470bd621..c6a2871c 100644 --- a/src/card-controller/lock/microphone-policy.ts +++ b/src/card-controller/lock/call-policy.ts @@ -4,12 +4,16 @@ import { isAdvancedCameraCardCustomAction } from '../../utils/action'; import { CardLockAPI } from '../types'; import type { LockPolicy } from './types'; -// Action that disrupt a hot-microphone session. Covers two categories: +// Actions that would disrupt an active call. Two categories: // - Major media changes (see `ViewManager.hasMajorMediaChange`): view, // camera, and substream changes. // - Stream-stopping / re-init actions: pause, reload, and casting (which // rehosts the stream to a media player). -const MICROPHONE_SESSION_DISRUPTIVE_ACTIONS: ReadonlySet = new Set([ +// +// `call_start` is intentionally absent — it's the entry into the lock. +// `call_end` is also absent — it dispatches via `setViewByParameters({ force: +// true })` to bypass the lock, so listing it here would be redundant. +const CALL_DISRUPTIVE_ACTIONS: ReadonlySet = new Set([ // View / camera / substream changes. ...VIEWS_USER_SPECIFIED, 'camera_select', @@ -31,7 +35,7 @@ const MICROPHONE_SESSION_DISRUPTIVE_ACTIONS: ReadonlySet = new Set([ 'media_player', ]); -export class MicrophoneLockPolicy implements LockPolicy { +export class CallLockPolicy implements LockPolicy { private _api: CardLockAPI; constructor(api: CardLockAPI) { @@ -39,17 +43,16 @@ export class MicrophoneLockPolicy implements LockPolicy { } public isActive(): boolean { - return this._api.getMicrophoneManager().isLocking(); + if (!this._api.getConfigManager().getConfig()?.live.controls.call.lock) { + return false; + } + return this._api.getCallManager().isActive(); } public shouldBlockAction(action: ActionConfig): boolean { - return this._isMicrophoneSessionDisruptiveAction(action); - } - - private _isMicrophoneSessionDisruptiveAction(action: ActionConfig): boolean { return ( isAdvancedCameraCardCustomAction(action) && - MICROPHONE_SESSION_DISRUPTIVE_ACTIONS.has(action.advanced_camera_card_action) + CALL_DISRUPTIVE_ACTIONS.has(action.advanced_camera_card_action) ); } } diff --git a/src/card-controller/lock/manager.ts b/src/card-controller/lock/manager.ts index e49dc739..11d486ea 100644 --- a/src/card-controller/lock/manager.ts +++ b/src/card-controller/lock/manager.ts @@ -1,7 +1,7 @@ import { ActionConfig, Actions } from '../../config/schema/actions/types'; import { arrayify } from '../../utils/basic'; import { CardLockAPI } from '../types'; -import { MicrophoneLockPolicy } from './microphone-policy'; +import { CallLockPolicy } from './call-policy'; import type { LockManagerEpoch, LockPolicy } from './types'; export class LockManager { @@ -9,7 +9,7 @@ export class LockManager { private _epoch: LockManagerEpoch | null = null; constructor(api: CardLockAPI) { - this._policies = [new MicrophoneLockPolicy(api)]; + this._policies = [new CallLockPolicy(api)]; } public isLocked(): boolean { diff --git a/src/card-controller/microphone-manager.ts b/src/card-controller/microphone-manager.ts index 7d451e20..5b8845dd 100644 --- a/src/card-controller/microphone-manager.ts +++ b/src/card-controller/microphone-manager.ts @@ -119,13 +119,6 @@ export class MicrophoneManager { return !this._stream || this._stream.getTracks().every((track) => !track.enabled); } - public isLocking(): boolean { - // The user-facing rationale: while the microphone is hot (e.g. mid 2-way - // audio), prevent accidental swipes, pauses, view changes. - const microphoneConfig = this._api.getConfigManager().getConfig()?.live.microphone; - return !!microphoneConfig?.lock && !this.isMuted(); - } - private _setDesiredMuteOnStream(): void { this._stream?.getTracks().forEach((track) => { track.enabled = !this._desireMute; diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index edea27f2..166d3fbc 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -6,7 +6,7 @@ import { createViewAction, } from '../utils/action.js'; import { CardQueryStringAPI } from './types'; -import { SubstreamSelectViewModifier } from './view/modifiers/substream-select'; +import { SubstreamViewModifier } from './view/modifiers/substream'; import { ViewParametersUserSpecified } from './view/types.js'; interface QueryStringViewIntent { @@ -50,7 +50,7 @@ export class QueryStringManager { camera: intent.view.camera, }, ...(intent.view.substream && { - modifiers: [new SubstreamSelectViewModifier(intent.view.substream)], + modifiers: [new SubstreamViewModifier(intent.view.substream)], }), }); } else { @@ -60,7 +60,7 @@ export class QueryStringManager { ...(intent.view.camera && { camera: intent.view.camera }), }, ...(intent.view.substream && { - modifiers: [new SubstreamSelectViewModifier(intent.view.substream)], + modifiers: [new SubstreamViewModifier(intent.view.substream)], }), }); } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index b656a33b..c2193f6e 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -7,6 +7,7 @@ import type { ResolvedMediaCache } from '../ha/resolved-media'; import type { EffectsManagerInterface } from '../types'; import type { ActionsManager } from './actions/actions-manager'; import type { AutomationsManager } from './automations-manager'; +import type { CallManager } from './call/manager'; import type { CameraURLManager } from './camera-url-manager'; import type { CardElementManager } from './card-element-manager'; import type { ConfigManager } from './config/config-manager'; @@ -41,6 +42,7 @@ import type { ViewManager } from './view/view-manager'; export interface CardActionsAPI { getActionsManager(): ActionsManager; + getCallManager(): CallManager; getCameraManager(): CameraManager; getCameraURLManager(): CameraURLManager; getCardElementManager(): CardElementManager; @@ -75,6 +77,14 @@ export interface CardAutomationsAPI { getIssueManager(): IssueManager; } +export interface CardCallAPI { + getCameraManager(): CameraManager; + getConditionStateManager(): ConditionStateManager; + getMicrophoneManager(): MicrophoneManager; + getNotificationManager(): NotificationManager; + getViewManager(): ViewManager; +} + export interface CardCameraAPI { getActionsManager(): ActionsManager; getConfigManager(): ConfigManager; @@ -237,7 +247,9 @@ export interface CardKeyboardStateAPI { } export interface CardLockAPI { - getMicrophoneManager(): MicrophoneManager; + getCallManager(): CallManager; + getConfigManager(): ConfigManager; + getViewManager(): ViewManager; } export interface CardMediaLoadedAPI { diff --git a/src/card-controller/view/modifiers/substream-off.ts b/src/card-controller/view/modifiers/substream-off.ts deleted file mode 100644 index 6184c311..00000000 --- a/src/card-controller/view/modifiers/substream-off.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { removeSubstream } from '../../../utils/substream'; -import { View } from '../../../view/view'; -import { ViewModifier } from '../types'; - -export class SubstreamOffViewModifier implements ViewModifier { - public modify(view: View): void { - removeSubstream(view); - } -} diff --git a/src/card-controller/view/modifiers/substream-on.ts b/src/card-controller/view/modifiers/substream-on.ts deleted file mode 100644 index 631c5327..00000000 --- a/src/card-controller/view/modifiers/substream-on.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { CameraManager } from '../../../camera-manager/manager'; -import { getStreamCameraID, setSubstream } from '../../../utils/substream'; -import { View } from '../../../view/view'; -import { ViewModifier } from '../types'; - -interface SubstreamOnViewModifierAPI { - getCameraManager(): CameraManager; -} - -export class SubstreamOnViewModifier implements ViewModifier { - private _api: SubstreamOnViewModifierAPI; - - constructor(api: SubstreamOnViewModifierAPI) { - this._api = api; - } - - public modify(view: View): void { - if (!view.camera) { - return; - } - - const dependencies = [ - ...this._api - .getCameraManager() - .getStore() - .getAllDependentCameras(view.camera, 'substream'), - ]; - - if (dependencies.length <= 1) { - return; - } - - const currentOverride = getStreamCameraID(view); - - /* istanbul ignore if: the if path cannot be reached, as there is a - view.camera guard at the start of this method and getStreamCameraID will - always return non-null as long as camera is present -- @preserve */ - if (!currentOverride) { - return; - } - const currentIndex = dependencies.indexOf(currentOverride); - const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length; - - setSubstream(view, dependencies[newIndex]); - } -} diff --git a/src/card-controller/view/modifiers/substream-select.ts b/src/card-controller/view/modifiers/substream-select.ts deleted file mode 100644 index e90302f8..00000000 --- a/src/card-controller/view/modifiers/substream-select.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { setSubstream } from '../../../utils/substream'; -import { View } from '../../../view/view'; -import { ViewModifier } from '../types'; - -export class SubstreamSelectViewModifier implements ViewModifier { - private _substreamID: string; - - constructor(substreamID: string) { - this._substreamID = substreamID; - } - - public modify(view: View): void { - setSubstream(view, this._substreamID); - } -} diff --git a/src/card-controller/view/modifiers/substream.ts b/src/card-controller/view/modifiers/substream.ts new file mode 100644 index 00000000..82945f87 --- /dev/null +++ b/src/card-controller/view/modifiers/substream.ts @@ -0,0 +1,30 @@ +import { View } from '../../../view/view'; +import { ViewModifier } from '../types'; + +// The single write path for the camera-keyed `live.overrides` map (the read +// path being `getStreamCameraID` in `view/substream`): sets a camera's +// substream override, or clears it when `substreamID` is absent so the +// camera's own stream is used. `cameraID` defaults to the selected camera. +export class SubstreamViewModifier implements ViewModifier { + private _substreamID?: string; + private _cameraID?: string; + + constructor(substreamID?: string, cameraID?: string) { + this._substreamID = substreamID; + this._cameraID = cameraID; + } + + public modify(view: View): void { + const cameraID = this._cameraID ?? view.camera; + if (!cameraID) { + return; + } + if (!this._substreamID) { + view.context?.live?.overrides?.delete(cameraID); + return; + } + const overrides = view.context?.live?.overrides ?? new Map(); + overrides.set(cameraID, this._substreamID); + view.mergeInContext({ live: { overrides } }); + } +} diff --git a/src/card-controller/view/view-manager.ts b/src/card-controller/view/view-manager.ts index 6b9344f2..394df02f 100644 --- a/src/card-controller/view/view-manager.ts +++ b/src/card-controller/view/view-manager.ts @@ -1,8 +1,8 @@ import { ViewContext } from 'view'; import { log } from '../../utils/debug'; -import { getStreamCameraID } from '../../utils/substream'; -import { View } from '../../view/view'; +import { getStreamCameraID } from '../../view/substream'; import { getViewTargetID } from '../../view/target-id'; +import { View } from '../../view/view'; import { InitializationAspect } from '../initialization-manager'; import { CardViewAPI } from '../types'; import { ViewFactory } from './factory'; @@ -91,12 +91,25 @@ export class ViewManager implements ViewManagerInterface { setViewByParametersWithExistingQuery = async ( options?: ViewFactoryOptions, - ): Promise => + ): Promise => { + // Default the query to the base view's own, so a bare `baseView` + // re-executes its query (`_setViewThenModifyAsync` otherwise nulls it). + // Only an omitted query falls back; an explicit query -- including `null` + // to clear it -- is left as the caller specified. + const baseView = options?.baseView ?? this._view; + const explicitQuery = options?.params?.query; await this._setViewThenModifyAsync( this._viewFactory.getViewByParameters.bind(this._viewFactory), this._viewQueryExecutor.getExistingQueryModifiers.bind(this._viewQueryExecutor), - options, + { + ...options, + params: { + ...options?.params, + query: explicitQuery !== undefined ? explicitQuery : baseView?.query ?? null, + }, + }, ); + }; private _setViewGeneric( viewFactoryFunc: (options?: ViewFactoryOptions) => View | null, @@ -374,11 +387,13 @@ export class ViewManager implements ViewManagerInterface { this._api.getStyleManager().setExpandedMode(); + const stream = view ? getStreamCameraID(view) : null; this._api.getConditionStateManager()?.setState({ view: view?.view, camera: view?.camera ?? undefined, displayMode: view?.displayMode ?? undefined, targetID: view ? getViewTargetID(view) ?? undefined : undefined, + substreamID: stream && stream !== view?.camera ? stream : undefined, }); this._api.getCardElementManager().update(); diff --git a/src/card.ts b/src/card.ts index 732b3da6..d5a9b535 100644 --- a/src/card.ts +++ b/src/card.ts @@ -8,6 +8,7 @@ import 'web-dialog'; import { actionHandler } from './action-handler-directive.js'; import { CardController } from './card-controller/controller'; import type { IssueKey, IssueTriggerEventData } from './card-controller/issues/types.js'; +import { resolveAutoHideState, type AutoHideState } from './components-lib/auto-hide.js'; import { MenuButtonController } from './components-lib/menu-button-controller'; import './components/effects/effects'; import './components/elements.js'; @@ -243,6 +244,10 @@ class AdvancedCameraCard extends LitElement { `; } + protected _getAutoHideState(): AutoHideState { + return resolveAutoHideState(this._controller.getCallManager().isActive()); + } + protected _renderMenu(slot?: string): TemplateResult | void { const view = this._controller.getViewManager().getView(); if (!this._hass || !this._config) { @@ -261,6 +266,7 @@ class AdvancedCameraCard extends LitElement { this._controller.getCameraManager(), this._controller.getFoldersManager(), { + callManager: this._controller.getCallManager(), currentMediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(), fullscreenManager: this._controller.getFullscreenManager(), inExpandedMode: this._controller.getExpandManager().isExpanded(), @@ -273,6 +279,7 @@ class AdvancedCameraCard extends LitElement { }, )} .entityRegistryManager=${this._controller.getEntityRegistryManager()} + .autoHideState=${this._getAutoHideState()} > `; } @@ -305,6 +312,7 @@ class AdvancedCameraCard extends LitElement { .getIssueDescriptions(), })} .config=${this._config.status_bar} + .autoHideState=${this._getAutoHideState()} > `; } @@ -427,6 +435,7 @@ class AdvancedCameraCard extends LitElement { .hide=${!!fullCardIssue} .microphoneManager=${this._controller.getMicrophoneManager()} .microphoneState=${this._controller.getMicrophoneManager().getState()} + .call=${this._controller.getCallManager().getCall() ?? undefined} .locked=${this._controller.getLockManager().isLocked()} .conditionStateManager=${this._controller.getConditionStateManager()} .triggeredCameraIDs=${this._config?.view.triggers.show_trigger_status diff --git a/src/components-lib/auto-hide.ts b/src/components-lib/auto-hide.ts new file mode 100644 index 00000000..d610c8e6 --- /dev/null +++ b/src/components-lib/auto-hide.ts @@ -0,0 +1,21 @@ +import { AutoHideCondition } from '../config/schema/common/auto-hide'; +import { isBeingCasted } from '../utils/casting'; + +// Whether each auto-hide condition is currently active. +export interface AutoHideState { + call: boolean; + casting: boolean; +} + +// Single constructor for the auto-hide state. `casting` is read from the +// environment; `call` is the only context-specific input, supplied by the +// caller (absent when no call notion applies, e.g. the media viewer). +export const resolveAutoHideState = (callActive = false): AutoHideState => ({ + call: callActive, + casting: isBeingCasted(), +}); + +export const isAutoHidden = ( + autoHide: readonly AutoHideCondition[], + state: AutoHideState, +): boolean => autoHide.some((condition) => state[condition]); diff --git a/src/components-lib/live/microphone-actions-controller.ts b/src/components-lib/live/microphone-actions-controller.ts index bae5589e..d6b5470f 100644 --- a/src/components-lib/live/microphone-actions-controller.ts +++ b/src/components-lib/live/microphone-actions-controller.ts @@ -26,6 +26,7 @@ interface MicrophoneActionsControllerOptions { export class MicrophoneActionsController { private _options: MicrophoneActionsControllerOptions | null = null; private _selectedCamera: string | null = null; + private _callActive = false; private _visibilityObserver: VisibilityObserver; constructor() { @@ -38,6 +39,27 @@ export class MicrophoneActionsController { this._options = options; } + /** + * Notifies the controller of the call-active state, acting only on a genuine + * transition. The initial state is treated as inactive, so a first-ever + * `true` counts -- the call rules apply even when the live view first + * appears during an active call. + * + * Call start unmutes the microphone only if the user opted into + * `microphone.auto_unmute: ['call']`. + */ + public setCallActive(active: boolean): void { + if (active === this._callActive) { + return; + } + this._callActive = active; + if (active) { + this._unmuteIfConfigured('call'); + } else { + this._muteIfConfigured('call'); + } + } + public setRoot(root: HTMLElement): void { this._visibilityObserver.setRoot(root); } diff --git a/src/components-lib/media-actions-controller.ts b/src/components-lib/media-actions-controller.ts index 60a1c242..a843e1e4 100644 --- a/src/components-lib/media-actions-controller.ts +++ b/src/components-lib/media-actions-controller.ts @@ -17,7 +17,6 @@ export interface MediaActionsControllerOptions { autoPauseConditions?: readonly AutoPauseCondition[]; autoMuteConditions?: readonly AutoMuteCondition[]; - microphoneState?: MicrophoneState; microphoneMuteSeconds?: number; } @@ -40,6 +39,16 @@ export class MediaActionsController { private _microphoneMuteTimer = new Timer(); private _root: RenderRoot | null = null; + // Audio-related state fed in via dedicated setters (not `setOptions`, which + // is pure configuration). + private _microphoneState?: MicrophoneState; + private _callActive = false; + + // Deferred because the media player is not always ready when a call starts: + // the call may start from another view, or engage a substream that is still + // loading. Applied by `_applyPendingCallStartAction`. + private _pendingCallStartAction = false; + private _eventListeners = new Map void>(); private _children: MediaPlayerElement[] = []; private _target: MediaActionsTarget | null = null; @@ -53,16 +62,34 @@ export class MediaActionsController { } public setOptions(options: MediaActionsControllerOptions): void { - if (this._options?.microphoneState !== options.microphoneState) { - this._microphoneStateChangeHandler( - this._options?.microphoneState, - options.microphoneState, - ); - } - this._options = options; } + public setMicrophoneState(state: MicrophoneState): void { + const previous = this._microphoneState; + this._microphoneState = state; + this._microphoneStateChangeHandler(previous, state); + } + + // Audio-out auto-mute/unmute driven by the call lifecycle: unmute on call + // start (hear the caller), mute on call end. Acts only on a genuine + // transition. The first-ever `true` counts as a transition: a carousel that + // loads while a call is already active (e.g. `call_start` dispatched from a + // non-live view) must still unmute. + public setCallActive(active: boolean): void { + if (active === this._callActive) { + return; + } + this._callActive = active; + if (active) { + this._pendingCallStartAction = true; + this._applyPendingCallStartAction(); + } else { + this._pendingCallStartAction = false; + this._muteTargetIfConfigured('call'); + } + } + public hasRoot(): boolean { return !!this._root; } @@ -94,6 +121,9 @@ export class MediaActionsController { index, }; + // A call may have started before this target existed; honor it now. + await this._applyPendingCallStartAction(); + if (selected) { await this._unmuteTargetIfConfigured('selected'); await this._playTargetIfConfigured('selected'); @@ -132,6 +162,30 @@ export class MediaActionsController { await (await this._children[index]?.getMediaPlayerController())?.unmute(); } + // The call-start action is currently a single unmute (hear the caller), + // applied once the media player is ready. The call-end mute is not deferred + // here: a mute that misses a not-yet-ready element is harmless, since + // elements start muted. + private async _applyPendingCallStartAction(): Promise { + if ( + !this._pendingCallStartAction || + this._target === null || + !this._options?.autoUnmuteConditions?.includes('call') + ) { + return; + } + + const controller = + await this._children[this._target.index]?.getMediaPlayerController(); + if (!controller) { + // Media not ready yet -- retried from `setTarget` / `_mediaLoadedHandler`. + return; + } + + this._pendingCallStartAction = false; + await controller.unmute(); + } + private async _pauseAllIfConfigured(condition: AutoPauseCondition): Promise { if (this._options?.autoPauseConditions?.includes(condition)) { for (const index of this._children.keys()) { @@ -190,6 +244,10 @@ export class MediaActionsController { // media load. const condition = this._target.selected ? 'selected' : 'visible'; await this._unmuteTargetIfConfigured(condition); + + // The media element is ready now; apply any call-start action that was + // deferred because it was not. + await this._applyPendingCallStartAction(); await this._playTargetIfConfigured(condition); }; diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index f85f77c7..1a5b3378 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -1,5 +1,6 @@ import { StyleInfo } from 'lit/directives/style-map'; import { CameraManager } from '../camera-manager/manager'; +import { CallManager } from '../card-controller/call/manager'; import { FoldersManager } from '../card-controller/folders/manager'; import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-manager'; import { MediaPlayerManager } from '../card-controller/media-player-manager'; @@ -17,6 +18,8 @@ import { HomeAssistant } from '../ha/types'; import { localize } from '../localize/localize.js'; import { MediaLoadedInfo } from '../types'; import { + createCallEndAction, + createCallStartAction, createCameraAction, createDisplayModeAction, createGeneralAction, @@ -30,8 +33,8 @@ import { import { arrayify, isTruthy } from '../utils/basic'; import { isBeingCasted } from '../utils/casting'; import { getPTZTarget } from '../utils/ptz'; -import { getStreamCameraID, hasSubstream } from '../utils/substream'; import { ViewItemClassifier } from '../view/item-classifier'; +import { getStreamCameraID, hasSubstream } from '../view/substream'; import { resolveViewName } from '../view/utils/resolve-default'; import { View } from '../view/view'; import { @@ -40,6 +43,7 @@ import { } from '../view/view-support'; export interface MenuButtonControllerOptions { + callManager?: CallManager | null; currentMediaLoadedInfo?: MediaLoadedInfo | null; showCameraUIButton?: boolean; fullscreenManager?: FullscreenManager | null; @@ -94,11 +98,13 @@ export class MenuButtonController { this._getInfoButton(config, cameraManager, options?.view), this._getSetReviewButton(config, options?.view), this._getCameraUIButton(config, options?.showCameraUIButton), + this._getCallButton(config, cameraManager, options?.callManager, options?.view), this._getMicrophoneButton( config, cameraManager, options?.view, options?.microphoneManager, + options?.callManager, ), this._getExpandButton(config, options?.inExpandedMode), this._getFullscreenButton(config, options?.fullscreenManager), @@ -480,17 +486,91 @@ export class MenuButtonController { : null; } + private _getCallButton( + config: AdvancedCameraCardConfig, + cameraManager: CameraManager, + callManager?: CallManager | null, + view?: View | null, + ): MenuItem | null { + if (!view?.camera || !view.is('live')) { + return null; + } + const cameraID = view.camera; + + // The call targets: the selected camera and/or any 2-way-audio-capable + // dependency. + const targets = [ + ...cameraManager.getStore().getAllDependentCameras(cameraID, '2-way-audio'), + ]; + if (!targets.length) { + return null; + } + + // In a call: a single hang-up button, regardless of target count. + if (callManager?.isActive()) { + return { + icon: 'mdi:phone-hangup', + title: localize('config.live.controls.call.end'), + style: this._getEmphasizedStyle(true), + ...config.menu.buttons.call, + type: 'custom:advanced-camera-card-menu-icon', + tap_action: createCallEndAction(), + }; + } + + // Idle, single target: a plain button (`call_start` resolves the default). + if (targets.length === 1) { + return { + icon: 'mdi:phone', + title: localize('config.live.controls.call.start'), + ...config.menu.buttons.call, + type: 'custom:advanced-camera-card-menu-icon', + tap_action: createCallStartAction(), + }; + } + + // Idle, multiple targets: a submenu, one entry per stream. + const menuItems = targets.map((streamID) => { + const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined; + return { + enabled: true, + icon: metadata?.icon.icon, + entity: metadata?.icon.entity, + state_color: true, + title: metadata?.title, + tap_action: createCallStartAction( + cameraID, + streamID === cameraID ? undefined : streamID, + ), + }; + }); + + return { + icon: 'mdi:phone', + title: localize('config.live.controls.call.start'), + ...config.menu.buttons.call, + type: 'custom:advanced-camera-card-menu-submenu', + items: menuItems, + }; + } + private _getMicrophoneButton( config: AdvancedCameraCardConfig, cameraManager: CameraManager, view?: View | null, microphoneManager?: MicrophoneManager | null, + callManager?: CallManager | null, ): MenuItem | null { const streamCameraID = view ? getStreamCameraID(view) : null; if (!streamCameraID) { return null; } + // The microphone only transmits during an active call. + if (!callManager?.isActive()) { + return null; + } + const capabilities = cameraManager.getCameraCapabilities(streamCameraID); if (microphoneManager && capabilities?.has('2-way-audio')) { diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index a6a8e6f2..ac6f941c 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -10,6 +10,7 @@ import type { MenuConfig } from '../config/schema/menu.js'; import type { Interaction } from '../types.js'; import { getActionConfigGivenAction } from '../utils/action'; import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js'; +import { AutoHideState, isAutoHidden as evaluateAutoHidden } from './auto-hide.js'; export class MenuController { private _host: LitElement; @@ -17,6 +18,7 @@ export class MenuController { private _buttons: MenuItem[] = []; private _expanded = false; private _lockManagerEpoch?: LockManagerEpoch; + private _autoHideState: AutoHideState | null = null; constructor(host: LitElement) { this._host = host; @@ -74,6 +76,21 @@ export class MenuController { return this._config; } + public setAutoHideState(state: AutoHideState): void { + this._autoHideState = state; + this._host.requestUpdate(); + } + + public shouldRender(): boolean { + if (!this._config || this._config.style === 'none') { + return false; + } + return !( + this._autoHideState && + evaluateAutoHidden(this._config.auto_hide, this._autoHideState) + ); + } + public isExpanded(): boolean { return this._expanded; } diff --git a/src/components-lib/status-bar-controller.ts b/src/components-lib/status-bar-controller.ts index 47a2ebe1..33b02e48 100644 --- a/src/components-lib/status-bar-controller.ts +++ b/src/components-lib/status-bar-controller.ts @@ -7,6 +7,7 @@ import { StatusBarConfig } from '../config/schema/status-bar'; import { getActionConfigGivenAction } from '../utils/action'; import { arrayify, setOrRemoveAttribute } from '../utils/basic'; import { Timer } from '../utils/timer'; +import { AutoHideState, isAutoHidden as evaluateAutoHidden } from './auto-hide'; export class StatusBarController { private _host: LitElement; @@ -14,6 +15,7 @@ export class StatusBarController { private _popupTimer = new Timer(); private _items: StatusBarItem[] = []; + private _autoHideState: AutoHideState | null = null; constructor(host: LitElement) { this._host = host; @@ -80,7 +82,19 @@ export class StatusBarController { return this._config; } + public setAutoHideState(state: AutoHideState): void { + this._autoHideState = state; + this._host.requestUpdate(); + } + public shouldRender(): boolean { + if ( + this._config && + this._autoHideState && + evaluateAutoHidden(this._config.auto_hide, this._autoHideState) + ) { + return false; + } return this._items.some( (item) => item.enabled !== false && (item.sufficient || item.permanent), ); diff --git a/src/components/call-controls.ts b/src/components/call-controls.ts new file mode 100644 index 00000000..d2a18d38 --- /dev/null +++ b/src/components/call-controls.ts @@ -0,0 +1,160 @@ +import { + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js'; +import { MicrophoneState } from '../card-controller/types.js'; +import { ActionConfig } from '../config/schema/actions/types.js'; +import { localize } from '../localize/localize.js'; +import callControlsStyle from '../scss/call-controls.scss'; +import { + createCallEndAction, + createGeneralAction, + stopEventFromActivatingCardWideActions, +} from '../utils/action.js'; +import { hasPopOutAnimationEnded } from '../utils/animation.js'; +import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event.js'; + +/** + * The on-screen overlay shown during an active two-way audio call: a centered + * pill with end-call, microphone-toggle, and mute-toggle buttons. + * + * This is a purely presentational control showing state and emitting intents. + * The end-call and microphone buttons dispatch actions; the audio-out button + * fires an `advanced-camera-card:call:mute-toggle` event for the host to act on. + */ +@customElement('advanced-camera-card-call-controls') +export class AdvancedCameraCardCallControls extends LitElement { + // Whether a call is in progress. + @property({ attribute: false }) + public active = false; + + @property({ attribute: false }) + public microphoneState?: MicrophoneState; + + @property({ attribute: false }) + public muted?: boolean; + + // The size, in pixels, of the control buttons. + @property({ attribute: false }) + public buttonSize?: number; + + // True while the exit animation plays after `active` turns false. + @state() + private _exiting = false; + + protected willUpdate(changedProps: PropertyValues): void { + if (changedProps.has('buttonSize') && this.buttonSize) { + this.style.setProperty( + '--advanced-camera-card-call-controls-button-size', + `${this.buttonSize}px`, + ); + } + + if (changedProps.has('active')) { + // Keep the pill visible through its exit animation when a call ends; a + // call (re)starting cancels any in-progress exit. + this._exiting = !this.active && !!changedProps.get('active'); + } + } + + protected render(): TemplateResult | void { + if (!this.active && !this._exiting) { + return; + } + + const microphoneMuted = this.microphoneState?.muted ?? true; + const audioAvailable = this.muted !== undefined; + const audioMuted = this.muted ?? true; + + return html`
+
stopEventFromActivatingCardWideActions(ev)} + @animationend=${this._handleAnimationEnd} + > + ${this._renderButton( + 'mdi:phone-hangup', + localize('config.live.controls.call.end'), + { + emphasis: 'critical', + action: createCallEndAction(), + }, + )} + ${this._renderButton( + microphoneMuted ? 'mdi:microphone-off' : 'mdi:microphone', + microphoneMuted + ? localize('config.live.controls.call.unmute_microphone') + : localize('config.live.controls.call.mute_microphone'), + { + emphasis: microphoneMuted ? undefined : 'critical', + action: createGeneralAction( + microphoneMuted ? 'microphone_unmute' : 'microphone_mute', + ), + }, + )} + ${this._renderButton( + audioMuted ? 'mdi:volume-off' : 'mdi:volume-high', + audioMuted + ? localize('config.live.controls.call.unmute_audio') + : localize('config.live.controls.call.mute_audio'), + { + disabled: !audioAvailable, + handler: () => fireAdvancedCameraCardEvent(this, 'call:mute-toggle'), + }, + )} +
+
`; + } + + private _handleAnimationEnd = (ev: AnimationEvent): void => { + if (hasPopOutAnimationEnded(ev)) { + this._exiting = false; + } + }; + + private _renderButton( + icon: string, + label: string, + options?: { + disabled?: boolean; + emphasis?: 'critical'; + action?: ActionConfig; + handler?: () => void; + }, + ): TemplateResult { + return html` + { + if (options?.handler) { + options.handler(); + } else if (options?.action) { + dispatchActionExecutionRequest(this, { actions: options.action }); + } + }} + > + + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(callControlsStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'advanced-camera-card-call-controls': AdvancedCameraCardCallControls; + } +} diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index e36c3762..bbcd5f49 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -11,8 +11,10 @@ import { keyed } from 'lit/directives/keyed.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { CameraManagerCameraMetadata } from '../../camera-manager/types.js'; +import { CallSession } from '../../card-controller/call/types.js'; import { MicrophoneState } from '../../card-controller/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { resolveAutoHideState } from '../../components-lib/auto-hide.js'; import { MediaActionsController } from '../../components-lib/media-actions-controller.js'; import { MediaHeightController } from '../../components-lib/media-height-controller.js'; import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js'; @@ -30,9 +32,10 @@ import { HomeAssistant } from '../../ha/types.js'; import liveCarouselStyle from '../../scss/live-carousel.scss'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { CarouselSelected } from '../../utils/embla/carousel-controller.js'; -import { getStreamCameraID } from '../../utils/substream.js'; import { getTextDirection } from '../../utils/text-direction.js'; +import { getStreamCameraID } from '../../view/substream.js'; import { View } from '../../view/view.js'; +import '../call-controls.js'; import '../carousel'; import '../next-prev-control.js'; import '../ptz.js'; @@ -70,6 +73,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { @property({ attribute: false }) public microphoneState?: MicrophoneState; + @property({ attribute: false }) + public call?: CallSession; + @property({ attribute: false }) public locked?: boolean; @@ -84,10 +90,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { private _ptzDragController = new PTZDragController(this); private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, { - getTargetID: () => - this.viewFilterCameraID ?? - this.viewManagerEpoch?.manager.getView()?.camera ?? - null, + getTargetID: () => this._getCarouselCameraID(), callback: () => this._mediaHeightController.recalculate(), }); @@ -138,6 +141,14 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { private _getTransitionEffect = (): TransitionEffect => this.liveConfig?.transition_effect ?? configDefaults.live.transition_effect; + // The cameraID this carousel currently represents: the filtered camera when + // the carousel is scoped to one, otherwise the camera of the active view. + private _getCarouselCameraID(): string | null { + return ( + this.viewFilterCameraID ?? this.viewManagerEpoch?.manager.getView()?.camera ?? null + ); + } + private _getSelectedCameraIndex(): number { if (this.viewFilterCameraID) { // If the carousel is limited to a single cameraID, the first (only) @@ -154,7 +165,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { } protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('microphoneState') || changedProps.has('liveConfig')) { + if (changedProps.has('liveConfig')) { this._mediaActionsController.setOptions({ playerSelector: ADVANCED_CAMERA_CARD_LIVE_PROVIDER, ...(this.liveConfig?.auto_play && { @@ -169,13 +180,27 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { ...(this.liveConfig?.auto_unmute && { autoUnmuteConditions: this.liveConfig.auto_unmute, }), - ...((this.liveConfig?.auto_unmute || this.liveConfig?.auto_mute) && { - microphoneState: this.microphoneState, + ...(this.liveConfig && { microphoneMuteSeconds: this.liveConfig.microphone.mute_after_microphone_mute_seconds, }), }); } + if (changedProps.has('microphoneState') && this.microphoneState) { + this._mediaActionsController.setMicrophoneState(this.microphoneState); + } + if ( + changedProps.has('call') || + changedProps.has('viewManagerEpoch') || + changedProps.has('viewFilterCameraID') + ) { + // Scope the call-active signal to the carousel that owns the call: in + // grid mode every carousel receives `.call`, but only the call camera's + // audio should be acted on. + this._mediaActionsController.setCallActive( + this.call?.cameraID === this._getCarouselCameraID(), + ); + } } private _getSlides(): TemplateResult[] { @@ -232,15 +257,14 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { const mediaEpoch = view?.context?.mediaEpoch?.[cameraID] ?? 0; const isSelectedSlide = !!view?.camera && cameraID === view.camera; + const microphoneStream = this._getRelevantMicrophoneStream(cameraID, view); return html`
${keyed( mediaEpoch, html` { @@ -354,12 +405,13 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { const hasMultipleCameras = slides.length > 1; const neighbors = this._getCameraNeighbors(); + const carouselCameraID = this._getCarouselCameraID(); const streamAwareCameraID = getStreamCameraID(view, this.viewFilterCameraID); const gesturesPTZActive = this._isGesturesPTZActive(view, streamAwareCameraID); const forcePTZVisibility = !this._mediaLoadedInfoSinkController.has() || - (!!this.viewFilterCameraID && this.viewFilterCameraID !== view.camera) || + carouselCameraID !== view.camera || view.context?.ptzControls?.enabled === false ? false : view.context?.ptzControls?.enabled; @@ -370,6 +422,10 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { !gesturesPTZActive && !this.locked; + const isCallActive = this.call?.cameraID === carouselCameraID; + const callMediaPlayerController = + this._mediaLoadedInfoSinkController.get()?.mediaPlayerController ?? null; + return html` + // Re-render so the call-controls are updated. + this.requestUpdate()} > ${this._renderNextPrevious('left', neighbors)} @@ -395,6 +454,14 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { .type=${this._getDisplayPTZType(streamAwareCameraID)} > + this._toggleMute()} + > + `; } diff --git a/src/components/live/grid.ts b/src/components/live/grid.ts index a756b3a5..d4279677 100644 --- a/src/components/live/grid.ts +++ b/src/components/live/grid.ts @@ -11,6 +11,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { MicrophoneState } from '../../card-controller/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { CallSession } from '../../card-controller/call/types.js'; import { MediaGridSelected } from '../../components-lib/media-grid-controller.js'; import { LiveConfig } from '../../config/schema/live.js'; import { CardWideConfig } from '../../config/schema/types.js'; @@ -39,6 +40,9 @@ export class AdvancedCameraCardLiveGrid extends LitElement { @property({ attribute: false }) public microphoneState?: MicrophoneState; + @property({ attribute: false }) + public call?: CallSession; + @property({ attribute: false }) public locked?: boolean; @@ -47,7 +51,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement { private _renderCarousel(cameraID?: string): TemplateResult { const view = this.viewManagerEpoch?.manager.getView(); - const triggeredCameraID = cameraID ?? view?.camera; + const carouselCameraID = cameraID ?? view?.camera; // Get the camera's grid width factor from its dimensions config. const gridWidthFactor = cameraID @@ -66,9 +70,13 @@ export class AdvancedCameraCardLiveGrid extends LitElement { .cardWideConfig=${this.cardWideConfig} .cameraManager=${this.cameraManager} .microphoneState=${this.microphoneState} + .call=${this.call} .locked=${this.locked} - ?triggered=${triggeredCameraID && - !!this.triggeredCameraIDs?.has(triggeredCameraID)} + ?triggered=${carouselCameraID && + !!this.triggeredCameraIDs?.has(carouselCameraID)} + ?transmitting=${this.microphoneState?.muted === false && + !!this.call && + this.call.cameraID === carouselCameraID} > `; diff --git a/src/components/live/index.ts b/src/components/live/index.ts index 34ddf3dc..cd4261e2 100644 --- a/src/components/live/index.ts +++ b/src/components/live/index.ts @@ -11,6 +11,7 @@ import { CameraManager } from '../../camera-manager/manager.js'; import { MicrophoneManager } from '../../card-controller/microphone-manager.js'; import { MicrophoneState } from '../../card-controller/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { CallSession } from '../../card-controller/call/types.js'; import { MicrophoneActionsController } from '../../components-lib/live/microphone-actions-controller.js'; import '../../components-lib/live/types.js'; import { LiveConfig } from '../../config/schema/live.js'; @@ -43,6 +44,9 @@ export class AdvancedCameraCardLive extends LitElement { @property({ attribute: false }) public microphoneState?: MicrophoneState; + @property({ attribute: false }) + public call?: CallSession; + @property({ attribute: false }) public locked?: boolean; @@ -81,6 +85,9 @@ export class AdvancedCameraCardLive extends LitElement { view?.is('live') ? view.camera ?? null : null, ); } + if (changedProps.has('call')) { + this._microphoneActionsController.setCallActive(!!this.call); + } } protected render(): TemplateResult | void { @@ -96,6 +103,7 @@ export class AdvancedCameraCardLive extends LitElement { .cardWideConfig=${this.cardWideConfig} .cameraManager=${this.cameraManager} .microphoneState=${this.microphoneState} + .call=${this.call} .locked=${this.locked} .triggeredCameraIDs=${this.triggeredCameraIDs} > diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 55fdca11..8ad9fca4 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -11,7 +11,6 @@ import { classMap } from 'lit/directives/class-map.js'; import { guard } from 'lit/directives/guard.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { Camera } from '../../camera-manager/camera.js'; -import { MicrophoneState } from '../../card-controller/types.js'; import { LazyLoadController } from '../../components-lib/lazy-load-controller.js'; import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js'; import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js'; @@ -51,7 +50,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP public cardWideConfig?: CardWideConfig; @property({ attribute: false }) - public microphoneState?: MicrophoneState; + public microphoneStream?: MediaStream | null; @property({ attribute: false }) public zoomSettings?: PartialZoomSettings | null; @@ -340,7 +339,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP .hass=${this.hass} .camera=${this.camera} .targetID=${this.targetID} - .microphoneState=${this.microphoneState} + .microphoneStream=${this.microphoneStream} .microphoneConfig=${this.liveConfig.microphone} ?controls=${this._getEffectiveBuiltinControls()} @advanced-camera-card:live:error=${(ev: Event) => diff --git a/src/components/live/providers/go2rtc/index.ts b/src/components/live/providers/go2rtc/index.ts index 871849c3..9e19d5e0 100644 --- a/src/components/live/providers/go2rtc/index.ts +++ b/src/components/live/providers/go2rtc/index.ts @@ -8,7 +8,6 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { Camera } from '../../../../camera-manager/camera.js'; -import { MicrophoneState } from '../../../../card-controller/types.js'; import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js'; import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js'; import { SignedURLController } from '../../../../components-lib/signed-url-controller.js'; @@ -35,7 +34,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer public targetID?: string; @property({ attribute: false }) - public microphoneState?: MicrophoneState; + public microphoneStream?: MediaStream | null; @property({ attribute: false }) public microphoneConfig?: MicrophoneConfig; @@ -103,7 +102,7 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer this._player = new VideoRTC(); this._player.targetID = this.targetID ?? null; this._player.mediaPlayerController = this._mediaPlayerController; - this._player.microphoneStream = this.microphoneState?.stream ?? null; + this._player.microphoneStream = this.microphoneStream ?? null; this._player.src = src; this._player.visibilityCheck = false; this._player.setControls(this.controls); @@ -137,11 +136,11 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer this._player.setControls(this.controls); } - if (this._player && changedProps.has('microphoneState')) { + if (this._player && changedProps.has('microphoneStream')) { // VideoRTC owns the transition: it updates microphoneStream, swaps the // track on the pre-armed transceiver, and validates against stale async // completions before any reconnect fallback. Fire-and-forget is fine. - /* async */ this._player.setMicrophoneStream(this.microphoneState?.stream ?? null); + /* async */ this._player.setMicrophoneStream(this.microphoneStream ?? null); } } diff --git a/src/components/menu.ts b/src/components/menu.ts index 57c97b0e..eba89264 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -3,6 +3,7 @@ import { customElement, property } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../action-handler-directive.js'; import type { LockManagerEpoch } from '../card-controller/lock/types'; +import type { AutoHideState } from '../components-lib/auto-hide.js'; import { MenuController } from '../components-lib/menu-controller.js'; import type { MenuItem } from '../config/schema/elements/custom/menu/types.js'; import type { MenuConfig } from '../config/schema/menu.js'; @@ -11,6 +12,7 @@ import type { EntityRegistryManager } from '../ha/registry/entity/types.js'; import type { HomeAssistant } from '../ha/types.js'; import menuStyle from '../scss/menu.scss'; import { hasAction } from '../utils/action.js'; +import { contentsChanged } from '../utils/basic.js'; import './icon.js'; import './submenu/select-button.js'; import './submenu/submenu-button'; @@ -28,6 +30,9 @@ export class AdvancedCameraCardMenu extends LitElement { @property({ attribute: false }) public lockManagerEpoch?: LockManagerEpoch; + @property({ attribute: false, hasChanged: contentsChanged }) + public autoHideState?: AutoHideState; + set menuConfig(menuConfig: MenuConfig) { this._controller.setMenuConfig(menuConfig); } @@ -44,6 +49,9 @@ export class AdvancedCameraCardMenu extends LitElement { if (changedProps.has('lockManagerEpoch')) { this._controller.setLockManagerEpoch(this.lockManagerEpoch); } + if (changedProps.has('autoHideState') && this.autoHideState) { + this._controller.setAutoHideState(this.autoHideState); + } } public toggleMenu(): void { @@ -156,9 +164,7 @@ export class AdvancedCameraCardMenu extends LitElement { } protected render(): TemplateResult | void { - const config = this._controller.getMenuConfig(); - const style = config?.style; - if (!config || style === 'none') { + if (!this._controller.shouldRender()) { return; } const matchingButtons = this._controller.getButtons('matching'); diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index ec9e5d19..dfbec261 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -1,10 +1,12 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { AutoHideState, isAutoHidden } from '../components-lib/auto-hide.js'; import { NextPreviousControlConfig } from '../config/schema/common/controls/next-previous.js'; import { Icon } from '../config/schema/common/icon.js'; import { HomeAssistant } from '../ha/types.js'; import controlStyle from '../scss/next-previous-control.scss'; +import { contentsChanged } from '../utils/basic.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; @@ -38,6 +40,9 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement { @property({ attribute: true, type: Boolean }) public disabled = false; + @property({ attribute: false, hasChanged: contentsChanged }) + public autoHideState?: AutoHideState; + // Label that is used for ARIA support and as tooltip. @property() label = ''; @@ -48,7 +53,13 @@ export class AdvancedCameraCardNextPreviousControl extends LitElement { ); protected render(): TemplateResult { - if (this.disabled || !this._controlConfig || this._controlConfig.style == 'none') { + if ( + this.disabled || + !this._controlConfig || + this._controlConfig.style == 'none' || + (this.autoHideState && + isAutoHidden(this._controlConfig.auto_hide, this.autoHideState)) + ) { return html``; } diff --git a/src/components/notification/popup.ts b/src/components/notification/popup.ts index d98bc8e3..7aa2a089 100644 --- a/src/components/notification/popup.ts +++ b/src/components/notification/popup.ts @@ -5,6 +5,7 @@ import { handleControlAction } from '../../components-lib/notification/action.js import { Notification } from '../../config/schema/actions/types.js'; import { localize } from '../../localize/localize.js'; import notificationPopupStyle from '../../scss/notification-popup.scss'; +import { hasPopOutAnimationEnded } from '../../utils/animation.js'; import { dispatchDismissNotificationEvent } from '../../utils/notification.js'; import { renderControl, @@ -80,7 +81,7 @@ export class AdvancedCameraCardNotification extends LitElement { }; private _handleAnimationEnd = (ev: AnimationEvent): void => { - if (ev.animationName === 'slideDown') { + if (hasPopOutAnimationEnded(ev)) { dispatchDismissNotificationEvent(this); } }; diff --git a/src/components/status-bar.ts b/src/components/status-bar.ts index 190fc36e..3710ca62 100644 --- a/src/components/status-bar.ts +++ b/src/components/status-bar.ts @@ -10,6 +10,7 @@ import { import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { actionHandler } from '../action-handler-directive.js'; +import type { AutoHideState } from '../components-lib/auto-hide.js'; import { StatusBarController } from '../components-lib/status-bar-controller'; import { StatusBarItem } from '../config/schema/actions/types.js'; import { StatusBarConfig } from '../config/schema/status-bar.js'; @@ -28,6 +29,9 @@ export class AdvancedCameraCardStatusBar extends LitElement { @property({ attribute: false }) public config?: StatusBarConfig; + @property({ attribute: false, hasChanged: contentsChanged }) + public autoHideState?: AutoHideState; + protected willUpdate(changedProperties: PropertyValues): void { // Always set config before items. if (changedProperties.has('config') && this.config) { @@ -37,6 +41,10 @@ export class AdvancedCameraCardStatusBar extends LitElement { if (changedProperties.has('items')) { this._controller.setItems(this.items ?? []); } + + if (changedProperties.has('autoHideState') && this.autoHideState) { + this._controller.setAutoHideState(this.autoHideState); + } } /** Theme-related styling is dynamically injected into the status bar depending on diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index 5666a72c..ed6a9085 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -13,6 +13,7 @@ import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../../camera-manager/manager.js'; import { RemoveContextPropertyViewModifier } from '../../card-controller/view/modifiers/remove-context-property.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; +import { resolveAutoHideState } from '../../components-lib/auto-hide.js'; import { MediaActionsController } from '../../components-lib/media-actions-controller.js'; import { MediaHeightController } from '../../components-lib/media-height-controller.js'; import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js'; @@ -302,6 +303,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { .controlConfig=${this.viewerConfig?.controls.next_previous} .thumbnail=${neighbors?.[scrollDirection]?.media.getThumbnail() ?? undefined} .label=${neighbors?.[scrollDirection]?.media.getTitle() ?? ''} + .autoHideState=${resolveAutoHideState()} ?disabled=${!neighbors?.[scrollDirection]} @click=${(ev: Event) => { scroll(scrollDirection); diff --git a/src/components/views.ts b/src/components/views.ts index db388738..263dfb71 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -15,6 +15,7 @@ import { MicrophoneManager } from '../card-controller/microphone-manager.js'; import { MicrophoneState } from '../card-controller/types.js'; import { ViewItemManager } from '../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../card-controller/view/types.js'; +import { CallSession } from '../card-controller/call/types.js'; import { ConditionStateManagerReadonlyInterface } from '../conditions/types.js'; import { AdvancedCameraCardConfig, CardWideConfig } from '../config/schema/types.js'; import { RawAdvancedCameraCardConfig } from '../config/types.js'; @@ -67,6 +68,9 @@ export class AdvancedCameraCardViews extends LitElement { @property({ attribute: false }) public microphoneState?: MicrophoneState; + @property({ attribute: false }) + public call?: CallSession; + @property({ attribute: false }) public locked?: boolean; @@ -240,6 +244,7 @@ export class AdvancedCameraCardViews extends LitElement { .cardWideConfig=${this.cardWideConfig} .microphoneManager=${this.microphoneManager} .microphoneState=${this.microphoneState} + .call=${this.call} .locked=${this.locked} .triggeredCameraIDs=${this.triggeredCameraIDs} class="${classMap(liveClasses)}" diff --git a/src/conditions/conditions-manager.ts b/src/conditions/conditions-manager.ts index cd6f0a8d..61d66aab 100644 --- a/src/conditions/conditions-manager.ts +++ b/src/conditions/conditions-manager.ts @@ -250,11 +250,11 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { }; case 'microphone': return { - result: - (condition.connected === undefined || - newState?.microphone?.connected === condition.connected) && - (condition.muted === undefined || - newState?.microphone?.muted === condition.muted), + result: newState?.microphone?.muted === condition.muted, + }; + case 'call': + return { + result: (condition.call ?? true) === (newState?.call ?? false), }; case 'key': return { diff --git a/src/conditions/types.ts b/src/conditions/types.ts index f2a75e94..14c18540 100644 --- a/src/conditions/types.ts +++ b/src/conditions/types.ts @@ -7,7 +7,11 @@ import { HomeAssistant } from '../ha/types'; import { MediaLoadedInfo } from '../types'; export interface ConditionState { + call?: boolean; camera?: string; + // The engaged substream for the selected camera (absent when the camera's own + // stream is used). + substreamID?: string; config?: AdvancedCameraCardConfig; displayMode?: ViewDisplayMode; expand?: boolean; diff --git a/src/config/management.ts b/src/config/management.ts index 4015cc5f..8f6f4cc3 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -791,6 +791,42 @@ const frigateCardToAdvancedCameraCardTransform = ( return modified; }; +/** + * Migrate a `condition: microphone` condition with the (removed) `connected` + * field into a `condition: call` node. Operates on a single condition object in + * place. When both `connected` and `muted` are present, splits into a + * two-condition `and` (the only way to preserve both semantics now that + * `connected` no longer lives on `microphone`). + * + * @returns `true` if the node was modified. + */ +const microphoneConnectedToCallTransform = (data: unknown): boolean => { + if (typeof data !== 'object' || !data || data['condition'] !== 'microphone') { + return false; + } + const connected = data['connected']; + if (typeof connected !== 'boolean') { + return false; + } + const muted = data['muted']; + + for (const key of Object.keys(data)) { + delete data[key]; + } + + if (typeof muted === 'boolean') { + data['condition'] = 'and'; + data['conditions'] = [ + { condition: 'call', call: connected }, + { condition: 'microphone', muted: muted }, + ]; + } else { + data['condition'] = 'call'; + data['call'] = connected; + } + return true; +}; + const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown => { if (typeof data !== 'object' || !data || Array.isArray(data)) { return data; @@ -999,4 +1035,20 @@ const UPGRADES = [ CONF_CAMERAS, upgradeWithOverrides('ptz', ptzIncorrectDataToWebRTCDataTransform), ), + + // microphone.connected → call condition migration. Conditions live under + // overrides, elements, and automations. + upgradeArrayOfObjects(CONF_OVERRIDES, (override) => + upgradeObjectRecursively(microphoneConnectedToCallTransform)(override), + ), + (data: unknown): boolean => { + return upgradeObjectRecursively(microphoneConnectedToCallTransform)( + typeof data === 'object' && data ? data[CONF_ELEMENTS] : {}, + ); + }, + (data: unknown): boolean => { + return upgradeObjectRecursively(microphoneConnectedToCallTransform)( + typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {}, + ); + }, ]; diff --git a/src/config/profiles/casting.ts b/src/config/profiles/casting.ts index 65f11faa..9b09251c 100644 --- a/src/config/profiles/casting.ts +++ b/src/config/profiles/casting.ts @@ -10,18 +10,16 @@ import { CONF_MENU_BUTTONS_MEDIA_PLAYER, CONF_MENU_BUTTONS_MUTE, CONF_MENU_BUTTONS_PLAY, - CONF_MENU_STYLE, } from '../../const.js'; export const CASTING_PROFILE = { [CONF_LIVE_CONTROLS_BUILTIN]: false, [CONF_MEDIA_VIEWER_CONTROLS_BUILTIN]: false, - // TVs are generally not touch-enabled, so we don't want to show the menu - [CONF_MENU_STYLE]: 'none', - - // But in case the user enables the menu, let's make sure to enable the - // buttons that make sense and disable the ones that don't + // TVs are generally not touch-enabled, so the menu auto-hides while casting + // (the `casting` default in `menu.auto_hide`). Should it nonetheless be + // shown, make sure the buttons that make sense are enabled and the ones that + // don't are disabled. [`${CONF_MENU_BUTTONS_PLAY}.enabled`]: true, [`${CONF_MENU_BUTTONS_MUTE}.enabled`]: true, [`${CONF_MENU_BUTTONS_FULLSCREEN}.enabled`]: false, diff --git a/src/config/schema/actions/custom/call-end.ts b/src/config/schema/actions/custom/call-end.ts new file mode 100644 index 00000000..f8bd5137 --- /dev/null +++ b/src/config/schema/actions/custom/call-end.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { advancedCameraCardCustomActionsBaseSchema } from './base'; + +export const callEndActionConfigSchema = + advancedCameraCardCustomActionsBaseSchema.extend({ + advanced_camera_card_action: z.literal('call_end'), + }); +export type CallEndActionConfig = z.infer; diff --git a/src/config/schema/actions/custom/call-start.ts b/src/config/schema/actions/custom/call-start.ts new file mode 100644 index 00000000..8c30d121 --- /dev/null +++ b/src/config/schema/actions/custom/call-start.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { advancedCameraCardCustomActionsBaseSchema } from './base'; + +export const callStartActionConfigSchema = + advancedCameraCardCustomActionsBaseSchema.extend({ + advanced_camera_card_action: z.literal('call_start'), + + // The camera to start the call on. Defaults to the selected camera. + camera: z.string().optional(), + + // The 2-way-audio stream to carry the call: Could be `camera` itself, or + // one of its 2-way-audio dependencies. Defaults to the first eligible. + stream: z.string().optional(), + }); +export type CallStartActionConfig = z.infer; diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index 7b316d77..dd11b913 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -3,6 +3,8 @@ import { linkSchema } from '../common/link'; import { severitySchema } from '../common/severity'; import { statusBarItemBaseSchema } from '../common/status-bar'; import { advancedCameraCardCustomActionsBaseSchema } from './custom/base'; +import { callEndActionConfigSchema } from './custom/call-end'; +import { callStartActionConfigSchema } from './custom/call-start'; import { cameraSelectActionConfigSchema } from './custom/camera-select'; import { viewDisplayModeActionConfigSchema } from './custom/display-mode'; import { effectActionConfigSchema } from './custom/effect'; @@ -58,6 +60,8 @@ export const statusBarActionConfigSchema: z.ZodSchema = }); const advancedCameraCardCustomActionSchema = z.union([ + callEndActionConfigSchema, + callStartActionConfigSchema, cameraSelectActionConfigSchema, effectActionConfigSchema, generalActionConfigSchema, diff --git a/src/config/schema/common/auto-hide.ts b/src/config/schema/common/auto-hide.ts new file mode 100644 index 00000000..cf9c1bdf --- /dev/null +++ b/src/config/schema/common/auto-hide.ts @@ -0,0 +1,4 @@ +// Conditions under which the menu or status bar auto-hides. +export const AUTO_HIDE_CONDITIONS = ['call', 'casting'] as const; + +export type AutoHideCondition = (typeof AUTO_HIDE_CONDITIONS)[number]; diff --git a/src/config/schema/common/controls/next-previous.ts b/src/config/schema/common/controls/next-previous.ts index 4bd90385..5fcab424 100644 --- a/src/config/schema/common/controls/next-previous.ts +++ b/src/config/schema/common/controls/next-previous.ts @@ -1,7 +1,9 @@ import { z } from 'zod'; +import { AUTO_HIDE_CONDITIONS } from '../auto-hide'; import { BUTTON_SIZE_MIN } from '../const'; export const nextPreviousControlConfigSchema = z.object({ + auto_hide: z.enum(AUTO_HIDE_CONDITIONS).array(), style: z.enum(['none', 'chevrons', 'icons', 'thumbnails']), size: z.number().min(BUTTON_SIZE_MIN), }); diff --git a/src/config/schema/common/media-actions.ts b/src/config/schema/common/media-actions.ts index 65749ea9..ff61bc77 100644 --- a/src/config/schema/common/media-actions.ts +++ b/src/config/schema/common/media-actions.ts @@ -4,15 +4,24 @@ export const MEDIA_ACTION_NEGATIVE_CONDITIONS = ['unselected', 'hidden'] as cons export const MEDIA_MUTE_CONDITIONS = [ ...MEDIA_ACTION_NEGATIVE_CONDITIONS, 'microphone', + 'call', ] as const; export const MEDIA_UNMUTE_CONDITIONS = [ ...MEDIA_ACTION_POSITIVE_CONDITIONS, 'microphone', + 'call', ] as const; -export const MICROPHONE_MUTE_CONDITIONS = MEDIA_ACTION_NEGATIVE_CONDITIONS; -export const MICROPHONE_UNMUTE_CONDITIONS = MEDIA_ACTION_POSITIVE_CONDITIONS; +export const MICROPHONE_MUTE_CONDITIONS = [ + ...MEDIA_ACTION_NEGATIVE_CONDITIONS, + 'call', +] as const; + +export const MICROPHONE_UNMUTE_CONDITIONS = [ + ...MEDIA_ACTION_POSITIVE_CONDITIONS, + 'call', +] as const; export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number]; diff --git a/src/config/schema/conditions/custom/call.ts b/src/config/schema/conditions/custom/call.ts new file mode 100644 index 00000000..5342e65a --- /dev/null +++ b/src/config/schema/conditions/custom/call.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const callConditionSchema = z.object({ + condition: z.literal('call'), + call: z.boolean().optional(), +}); diff --git a/src/config/schema/conditions/custom/microphone.ts b/src/config/schema/conditions/custom/microphone.ts index 0407b2f0..e0d36e2e 100644 --- a/src/config/schema/conditions/custom/microphone.ts +++ b/src/config/schema/conditions/custom/microphone.ts @@ -2,6 +2,5 @@ import { z } from 'zod'; export const microphoneConditionSchema = z.object({ condition: z.literal('microphone'), - connected: z.boolean().optional(), - muted: z.boolean().optional(), + muted: z.boolean(), }); diff --git a/src/config/schema/conditions/types.ts b/src/config/schema/conditions/types.ts index 4ab7ffc6..16210de8 100644 --- a/src/config/schema/conditions/types.ts +++ b/src/config/schema/conditions/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { callConditionSchema } from './custom/call'; import { cameraConditionSchema } from './custom/camera'; import { configConditionSchema } from './custom/config'; import { displayModeConditionSchema } from './custom/display-mode'; @@ -69,6 +70,7 @@ export const advancedCameraCardConditionSchema = z.union([ templateConditionSchema, // Custom conditions: + callConditionSchema, cameraConditionSchema, configConditionSchema, displayModeConditionSchema, diff --git a/src/config/schema/live.ts b/src/config/schema/live.ts index 8d0ef4d5..67efed79 100644 --- a/src/config/schema/live.ts +++ b/src/config/schema/live.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { actionsSchema } from './actions/types'; +import { BUTTON_SIZE_MIN } from './common/const'; import { nextPreviousControlConfigSchema } from './common/controls/next-previous'; import { ptzControlsConfigSchema, ptzControlsDefaults } from './common/controls/ptz'; import { @@ -23,23 +24,37 @@ import { transitionEffectConfigSchema } from './common/transition-effect'; const microphoneConfigDefault = { always_connected: false, - auto_mute: [], + auto_mute: ['call' as const], auto_unmute: [], disconnect_seconds: 90, - lock: true, mute_after_microphone_mute_seconds: 60, }; +const callConfigDefault = { + button_size: 40, + lock: true, +}; + +const callConfigSchema = z.object({ + button_size: z.number().min(BUTTON_SIZE_MIN).default(callConfigDefault.button_size), + lock: z.boolean().default(callConfigDefault.lock), +}); + const microphoneConfigSchema = z .object({ always_connected: z.boolean().default(microphoneConfigDefault.always_connected), - auto_mute: z.enum(MICROPHONE_MUTE_CONDITIONS).array().default([]), - auto_unmute: z.enum(MICROPHONE_UNMUTE_CONDITIONS).array().default([]), + auto_mute: z + .enum(MICROPHONE_MUTE_CONDITIONS) + .array() + .default(microphoneConfigDefault.auto_mute), + auto_unmute: z + .enum(MICROPHONE_UNMUTE_CONDITIONS) + .array() + .default(microphoneConfigDefault.auto_unmute), disconnect_seconds: z .number() .min(0) .default(microphoneConfigDefault.disconnect_seconds), - lock: z.boolean().default(microphoneConfigDefault.lock), mute_after_microphone_mute_seconds: z .number() .min(0) @@ -52,7 +67,7 @@ export const liveConfigDefault = { auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS], auto_pause: [], auto_mute: [...MEDIA_MUTE_CONDITIONS], - auto_unmute: ['microphone' as const], + auto_unmute: ['microphone' as const, 'call' as const], preload: false, lazy_load: true, lazy_unload: [], @@ -62,7 +77,9 @@ export const liveConfigDefault = { show_image_during_load: true, controls: { builtin: true, + call: { ...callConfigDefault }, next_previous: { + auto_hide: ['call' as const, 'casting' as const], size: 48, style: 'chevrons' as const, }, @@ -97,8 +114,12 @@ export const liveConfigSchema = z controls: z .object({ builtin: z.boolean().default(liveConfigDefault.controls.builtin), + call: callConfigSchema.default(liveConfigDefault.controls.call), next_previous: nextPreviousControlConfigSchema .extend({ + auto_hide: nextPreviousControlConfigSchema.shape.auto_hide.default( + liveConfigDefault.controls.next_previous.auto_hide, + ), // Live cannot show thumbnails, remove that option. style: z .enum(['none', 'chevrons', 'icons']) diff --git a/src/config/schema/menu.ts b/src/config/schema/menu.ts index 00140fc1..51b563a2 100644 --- a/src/config/schema/menu.ts +++ b/src/config/schema/menu.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { AUTO_HIDE_CONDITIONS } from './common/auto-hide'; import { BUTTON_SIZE_MIN, MENU_PRIORITY_DEFAULT } from './common/const'; import { menuBaseSchema } from './elements/custom/menu/base'; @@ -32,10 +33,12 @@ const hiddenButtonDefault = { export const menuConfigDefault = { alignment: 'left' as const, + auto_hide: ['call' as const, 'casting' as const], button_size: 40, buttons: { // Clone per key so each button has its own default object. This avoids // shared nested default references between keys. + call: { ...visibleButtonDefault }, camera_ui: { ...visibleButtonDefault }, cameras: { ...visibleButtonDefault }, clips: { ...hiddenButtonDefault }, @@ -86,8 +89,10 @@ export const menuConfigSchema = z style: z.enum(MENU_STYLES).default(menuConfigDefault.style), position: z.enum(MENU_POSITIONS).default(menuConfigDefault.position), alignment: z.enum(MENU_ALIGNMENTS).default(menuConfigDefault.alignment), + auto_hide: z.enum(AUTO_HIDE_CONDITIONS).array().default(menuConfigDefault.auto_hide), buttons: z .object({ + call: visibleButtonSchema.default(menuConfigDefault.buttons.call), camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui), cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras), clips: hiddenButtonSchema.default(menuConfigDefault.buttons.clips), diff --git a/src/config/schema/status-bar.ts b/src/config/schema/status-bar.ts index 8271b9e4..b4ea3280 100644 --- a/src/config/schema/status-bar.ts +++ b/src/config/schema/status-bar.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { AUTO_HIDE_CONDITIONS } from './common/auto-hide'; import { BUTTON_SIZE_MIN, STATUS_BAR_PRIORITY_DEFAULT } from './common/const'; import { statusBarItemBaseSchema } from './common/status-bar'; @@ -36,6 +37,7 @@ const statusBarIssuesItemSchema = statusBarItemBaseSchema.extend({ }); export const statusBarConfigDefault = { + auto_hide: ['call' as const, 'casting' as const], height: 40, items: { engine: statusBarItemDefault, @@ -52,6 +54,10 @@ export const statusBarConfigDefault = { export const statusBarConfigSchema = z .object({ + auto_hide: z + .enum(AUTO_HIDE_CONDITIONS) + .array() + .default(statusBarConfigDefault.auto_hide), position: z.enum(STATUS_BAR_POSITIONS).default(statusBarConfigDefault.position), style: z.enum(STATUS_BAR_STYLES).default(statusBarConfigDefault.style), popup_seconds: z diff --git a/src/config/schema/viewer.ts b/src/config/schema/viewer.ts index 349e970d..64b77b9c 100644 --- a/src/config/schema/viewer.ts +++ b/src/config/schema/viewer.ts @@ -30,6 +30,7 @@ export const viewerConfigDefault = { controls: { builtin: true, next_previous: { + auto_hide: ['casting' as const], size: 48, style: 'thumbnails' as const, }, @@ -44,6 +45,12 @@ export const viewerConfigDefault = { }; const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({ + // Calls only occur in the live view, so `call` is dropped from the common + // enum. + auto_hide: z + .enum(['casting']) + .array() + .default(viewerConfigDefault.controls.next_previous.auto_hide), style: z .enum(['none', 'thumbnails', 'chevrons']) .default(viewerConfigDefault.controls.next_previous.style), diff --git a/src/const.ts b/src/const.ts index 670e84ec..e18f8d6d 100644 --- a/src/const.ts +++ b/src/const.ts @@ -244,6 +244,8 @@ export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT = `${CONF_MEDIA_VIEWER}.transition_effect` as const; export const CONF_MEDIA_VIEWER_CONTROLS_BUILTIN = `${CONF_MEDIA_VIEWER}.controls.builtin` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE = + `${CONF_MEDIA_VIEWER}.controls.next_previous.auto_hide` as const; export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE = `${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const; export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE = @@ -289,6 +291,11 @@ export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const; export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const; export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const; export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as const; +export const CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE = + `${CONF_LIVE}.controls.call.button_size` as const; +export const CONF_LIVE_CONTROLS_CALL_LOCK = `${CONF_LIVE}.controls.call.lock` as const; +export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE = + `${CONF_LIVE}.controls.next_previous.auto_hide` as const; export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE = `${CONF_LIVE}.controls.next_previous.style` as const; export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE = @@ -363,7 +370,6 @@ export const CONF_LIVE_MICROPHONE_AUTO_UNMUTE = `${CONF_LIVE}.microphone.auto_unmute` as const; export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS = `${CONF_LIVE}.microphone.disconnect_seconds` as const; -export const CONF_LIVE_MICROPHONE_LOCK = `${CONF_LIVE}.microphone.lock` as const; export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS = `${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const; export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const; @@ -406,6 +412,7 @@ export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL = const CONF_MENU = 'menu' as const; export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const; +export const CONF_MENU_AUTO_HIDE = `${CONF_MENU}.auto_hide` as const; export const CONF_MENU_POSITION = `${CONF_MENU}.position` as const; export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const; export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const; @@ -420,6 +427,7 @@ export const CONF_MENU_BUTTONS_MEDIA_PLAYER = export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU_BUTTONS}.timeline` as const; export const CONF_STATUS_BAR = 'status_bar' as const; +export const CONF_STATUS_BAR_AUTO_HIDE = `${CONF_STATUS_BAR}.auto_hide` as const; export const CONF_STATUS_BAR_POSITION = `${CONF_STATUS_BAR}.position` as const; export const CONF_STATUS_BAR_STYLE = `${CONF_STATUS_BAR}.style` as const; export const CONF_STATUS_BAR_POPUP_SECONDS = `${CONF_STATUS_BAR}.popup_seconds` as const; @@ -461,3 +469,6 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; // improved rendering performance. export const MEDIA_CHUNK_SIZE_DEFAULT = 50; export const MEDIA_CHUNK_SIZE_MAX = 1000; + +// The name of the exit keyframe defined in `scss/pop-animation.scss`. +export const POP_OUT_ANIMATION_NAME = 'pop-out'; diff --git a/src/editor.ts b/src/editor.ts index f2d0ef36..132315db 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -121,6 +121,9 @@ import { CONF_LIVE_AUTO_PLAY, CONF_LIVE_AUTO_UNMUTE, CONF_LIVE_CONTROLS_BUILTIN, + CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE, + CONF_LIVE_CONTROLS_CALL_LOCK, + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_LIVE_CONTROLS_PTZ_HIDE_HOME, @@ -159,7 +162,6 @@ import { CONF_LIVE_MICROPHONE_AUTO_MUTE, CONF_LIVE_MICROPHONE_AUTO_UNMUTE, CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS, - CONF_LIVE_MICROPHONE_LOCK, CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS, CONF_LIVE_PRELOAD, CONF_LIVE_SHOW_IMAGE_DURING_LOAD, @@ -178,6 +180,7 @@ import { CONF_MEDIA_VIEWER_AUTO_PLAY, CONF_MEDIA_VIEWER_AUTO_UNMUTE, CONF_MEDIA_VIEWER_CONTROLS_BUILTIN, + CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, @@ -207,6 +210,7 @@ import { CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_ZOOMABLE, CONF_MENU_ALIGNMENT, + CONF_MENU_AUTO_HIDE, CONF_MENU_BUTTON_SIZE, CONF_MENU_BUTTONS, CONF_MENU_POSITION, @@ -221,6 +225,7 @@ import { CONF_PERFORMANCE_STYLE_BOX_SHADOW, CONF_PROFILES, CONF_REMOTE_CONTROL_ENTITIES_CAMERA, + CONF_STATUS_BAR_AUTO_HIDE, CONF_STATUS_BAR_HEIGHT, CONF_STATUS_BAR_ITEMS, CONF_STATUS_BAR_POPUP_SECONDS, @@ -308,6 +313,7 @@ const MENU_CAMERAS_MEDIA = 'cameras.media'; const MENU_FOLDERS = 'folders'; const MENU_FOLDERS_HA = 'folders.ha'; const MENU_LIVE_CONTROLS = 'live.controls'; +const MENU_LIVE_CONTROLS_CALL = 'live.controls.call'; const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous'; const MENU_LIVE_CONTROLS_PTZ = 'live.controls.ptz'; const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails'; @@ -384,6 +390,7 @@ const SUBMENU_DOC_LINKS: Record = { [MENU_FOLDERS]: 'configuration/folders', [MENU_FOLDERS_HA]: 'configuration/folders?id=ha', [MENU_LIVE_CONTROLS]: 'configuration/live?id=controls', + [MENU_LIVE_CONTROLS_CALL]: 'configuration/live?id=call', [MENU_LIVE_CONTROLS_NEXT_PREVIOUS]: 'configuration/live?id=next_previous', [MENU_LIVE_CONTROLS_PTZ]: 'configuration/live?id=ptz', [MENU_LIVE_CONTROLS_THUMBNAILS]: 'configuration/live?id=thumbnails', @@ -770,12 +777,33 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard }, ]; + private _callMuteCondition: EditorSelectOption = { + value: 'call', + label: localize('config.common.media_action_conditions.call_mute'), + }; + + private _callUnmuteCondition: EditorSelectOption = { + value: 'call', + label: localize('config.common.media_action_conditions.call_unmute'), + }; + + private _microphoneMuteConditions: EditorSelectOption[] = [ + ...this._mediaActionNegativeConditions, + this._callMuteCondition, + ]; + + private _microphoneUnmuteConditions: EditorSelectOption[] = [ + ...this._mediaActionPositiveConditions, + this._callUnmuteCondition, + ]; + private _mediaLiveUnmuteConditions: EditorSelectOption[] = [ ...this._mediaActionPositiveConditions, { value: 'microphone', label: localize('config.common.media_action_conditions.microphone_unmute'), }, + this._callUnmuteCondition, ]; private _mediaLiveMuteConditions: EditorSelectOption[] = [ @@ -784,6 +812,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard value: 'microphone', label: localize('config.common.media_action_conditions.microphone_mute'), }, + this._callMuteCondition, + ]; + + private _autoHideConditions: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'call', label: localize('config.common.auto_hide_conditions.call') }, + { + value: 'casting', + label: localize('config.common.auto_hide_conditions.casting'), + }, ]; private _layoutFits: EditorSelectOption[] = [ @@ -1973,9 +2011,11 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard domain: string, configPathStyle: string, configPathSize: string, + configPathAutoHide: string, options?: { allowIcons?: boolean; allowThumbnails?: boolean; + allowCall?: boolean; }, ): TemplateResult | void { return this._putInSubmenu( @@ -1999,6 +2039,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard min: BUTTON_SIZE_MIN, label: localize('config.common.controls.next_previous.size'), })} + ${this._renderOptionSelector( + configPathAutoHide, + this._autoHideConditions.filter( + (item) => !!options?.allowCall || item.value !== 'call', + ), + { + multiple: true, + label: localize('config.common.controls.next_previous.auto_hide'), + }, + )} `, ); } @@ -3093,11 +3143,20 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderOptionSelector(CONF_MENU_STYLE, this._menuStyles)} ${this._renderOptionSelector(CONF_MENU_POSITION, this._menuPositions)} ${this._renderOptionSelector(CONF_MENU_ALIGNMENT, this._menuAlignments)} + ${this._renderOptionSelector( + CONF_MENU_AUTO_HIDE, + this._autoHideConditions, + { + multiple: true, + label: localize('config.menu.auto_hide'), + }, + )} ${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, { min: BUTTON_SIZE_MIN, })} ${[ this._renderMenuButton('iris'), + this._renderMenuButton('call'), this._renderMenuButton('camera_ui'), this._renderMenuButton('cameras'), this._renderMenuButton('clips'), @@ -3150,6 +3209,14 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_STATUS_BAR_POSITION, this._statusBarPositions, )} + ${this._renderOptionSelector( + CONF_STATUS_BAR_AUTO_HIDE, + this._autoHideConditions, + { + multiple: true, + label: localize('config.status_bar.auto_hide'), + }, + )} ${this._renderNumberInput(CONF_STATUS_BAR_HEIGHT, { min: STATUS_BAR_HEIGHT_MIN, label: localize('config.status_bar.height'), @@ -3253,12 +3320,29 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard label: localize('config.common.controls.wheel'), }, )} + ${this._putInSubmenu( + MENU_LIVE_CONTROLS_CALL, + true, + 'config.live.controls.call.editor_label', + 'mdi:phone', + html` + ${this._renderSwitch( + CONF_LIVE_CONTROLS_CALL_LOCK, + this._defaults.live.controls.call.lock, + )} + ${this._renderNumberInput(CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE, { + min: BUTTON_SIZE_MIN, + })} + `, + )} ${this._renderNextPreviousControls( MENU_LIVE_CONTROLS_NEXT_PREVIOUS, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, + CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE, { allowIcons: true, + allowCall: true, }, )} ${this._renderThumbnailsControls( @@ -3353,20 +3437,16 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED, this._defaults.live.microphone.always_connected, )} - ${this._renderSwitch( - CONF_LIVE_MICROPHONE_LOCK, - this._defaults.live.microphone.lock, - )} ${this._renderOptionSelector( CONF_LIVE_MICROPHONE_AUTO_MUTE, - this._mediaActionNegativeConditions, + this._microphoneMuteConditions, { multiple: true, }, )} ${this._renderOptionSelector( CONF_LIVE_MICROPHONE_AUTO_UNMUTE, - this._mediaActionPositiveConditions, + this._microphoneUnmuteConditions, { multiple: true, }, @@ -3498,6 +3578,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, + CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE, { allowThumbnails: true, }, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 015df191..0e35d38d 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -207,6 +207,10 @@ } }, "common": { + "auto_hide_conditions": { + "call": "During a two-way audio call", + "casting": "While casting" + }, "controls": { "builtin": "Built-in video controls", "filter": { @@ -219,6 +223,7 @@ } }, "next_previous": { + "auto_hide": "Automatically hide next & previous controls", "editor_label": "Next & Previous", "size": "Next & previous control size in pixels", "style": "Next & previous control style", @@ -310,6 +315,8 @@ "inactive": "Only when not interacting" }, "media_action_conditions": { + "call_mute": "On call end", + "call_unmute": "On call start", "hidden": "On browser/tab hiding", "microphone_mute": "On microphone mute", "microphone_unmute": "On microphone unmute", @@ -387,6 +394,17 @@ "auto_play": "Automatically play live cameras", "auto_unmute": "Automatically unmute live cameras", "controls": { + "call": { + "button_size": "Call control button size", + "editor_label": "Two-way audio call", + "end": "End 2-way audio call", + "lock": "Lock UI during an active call", + "mute_audio": "Mute audio", + "mute_microphone": "Mute microphone", + "start": "Start 2-way audio call", + "unmute_audio": "Unmute audio", + "unmute_microphone": "Unmute microphone" + }, "editor_label": "Live Controls", "ptz": { "editor_label": "PTZ", @@ -427,7 +445,6 @@ "auto_unmute": "Automatically unmute the microphone", "disconnect_seconds": "Seconds before disconnecting microphone (0=never)", "editor_label": "Microphone", - "lock": "Lock the UI while the microphone is unmuted", "mute_after_microphone_mute_seconds": "Seconds after microphone mute before muting inbound audio" }, "preload": "Preload live view in the background", @@ -461,6 +478,7 @@ "right": "Aligned to the right", "top": "Aligned to the top" }, + "auto_hide": "Conditions under which the menu auto-hides", "button_size": "Menu button size in pixels", "buttons": { "alignment": "Button alignment", @@ -468,6 +486,7 @@ "matching": "Matching the menu alignment", "opposing": "Opposing the menu alignment" }, + "call": "Call / Two-way audio", "camera_ui": "Camera user interface", "cameras": "Cameras", "clips": "Clips", @@ -555,6 +574,7 @@ } }, "status_bar": { + "auto_hide": "Conditions under which the status bar auto-hides", "height": "Status bar height in pixels", "items": { "enabled": "Item enabled", @@ -726,6 +746,11 @@ "error": { "awaiting_live": "Waiting for live stream to load...", "awaiting_media": "Waiting for media to load", + "call_unavailable_heading": "Two-way audio unavailable", + "call_invalid_target": "The requested camera or stream is not available to call.", + "call_microphone_forbidden": "Microphone access has been denied for this page. Update your browser permissions and try again.", + "call_microphone_unsupported": "Microphone access is not available in this browser (e.g. requires HTTPS).", + "call_no_two_way_audio": "This camera does not support two-way audio.", "camera_initialization": "Camera initialization failed", "camera_initialization_reolink": "Could not initialize Reolink camera", "configuration": "Check configuration", diff --git a/src/scss/call-controls.scss b/src/scss/call-controls.scss new file mode 100644 index 00000000..0dd0397e --- /dev/null +++ b/src/scss/call-controls.scss @@ -0,0 +1,56 @@ +@use './z-index.scss' as *; +@use './pop-animation.scss' as *; + +:host { + position: absolute; + inset-inline: 0; + bottom: 16px; + display: block; + pointer-events: none; + z-index: $z-index-call-controls; + + --advanced-camera-card-call-controls-button-size: 40px; + --ha-icon-button-size: var(--advanced-camera-card-call-controls-button-size); + --mdc-icon-size: calc(var(--ha-icon-button-size) / 2); +} + +.overlay { + display: flex; + justify-content: center; + padding: 0 16px; +} + +.panel { + pointer-events: auto; + display: flex; + align-items: center; + gap: 4px; + padding: 5px 7px; + border-radius: var(--advanced-camera-card-button-border-radius); + background: var(--advanced-camera-card-call-controls-background); + box-shadow: var( + --advanced-camera-card-box-shadow-override, + 0 10px 30px rgba(0, 0, 0, 0.25) + ); + backdrop-filter: blur(10px); + + @include pop-in; +} + +.panel.exiting { + @include pop-out; + + // The pill lingers in the DOM for the exit animation; stop it taking clicks + // so a stale hangup/mute can't fire after the call has already ended. + pointer-events: none; +} + +ha-icon-button { + color: var(--advanced-camera-card-button-color); + background: var(--advanced-camera-card-button-background); + border-radius: var(--advanced-camera-card-button-border-radius); + + &.critical { + color: var(--advanced-camera-card-call-controls-critical-color); + } +} diff --git a/src/scss/elements.scss b/src/scss/elements.scss index b8d8d1b5..0caad170 100644 --- a/src/scss/elements.scss +++ b/src/scss/elements.scss @@ -1,7 +1,13 @@ +@use './z-index.scss' as *; + :host { position: absolute; inset: 0; + // Picture elements always render above view content (media, gallery, + // thumbnails) regardless of any z-index those surfaces use internally. + z-index: $z-index-elements; + // Don't let elements overflow. overflow: hidden; pointer-events: none; diff --git a/src/scss/live-grid.scss b/src/scss/live-grid.scss index e31333c1..193afd0d 100644 --- a/src/scss/live-grid.scss +++ b/src/scss/live-grid.scss @@ -2,13 +2,13 @@ @keyframes warning-pulse { 0% { - border: solid 2px var(--trigger-border-color-base); + border-color: var(--trigger-border-color-base); } 50% { - border: solid 2px var(--trigger-border-color); + border-color: var(--trigger-border-color); } 100% { - border: solid 2px var(--trigger-border-color-base); + border-color: var(--trigger-border-color-base); } } @@ -18,11 +18,34 @@ advanced-camera-card-live-carousel { --advanced-camera-card-trigger-border-color-base, black ); + + transition: border-color 0.3s ease-out; +} + +// `:host >` matches the carousel only in single display mode (in grid mode it +// is nested inside `media-grid`). The single-mode carousel has no border of its +// own, so reserve a transparent one so border changes (e.g. trigger) only +// recolour. In grid mode the border is supplied (already width-reserved) by +// `media-grid`. +:host > advanced-camera-card-live-carousel { + box-sizing: border-box; + border: solid 2px transparent; } advanced-camera-card-live-carousel[triggered] { animation: warning-pulse 5s infinite; } +// The `:host` prefix raises specificity to (0,2,1), above `media-grid`'s +// `::slotted([selected])` border (0,1,1). In grid mode that rule lives in an +// inner shadow tree and would otherwise win the cross-tree cascade tie, +// leaving the transmitting border the wrong colour. `[triggered]` needs no +// such treatment — animated values always beat the regular cascade. +:host advanced-camera-card-live-carousel[transmitting] { + // `animation: none` cancels any concurrent trigger pulse to ensure + // transmitting takes precedence. + animation: none; + border-color: var(--advanced-camera-card-transmitting-border-color); +} advanced-camera-card-live-carousel[selected] { --trigger-border-color-base: var( --advanced-camera-card-trigger-border-color-base, diff --git a/src/scss/next-previous-control.scss b/src/scss/next-previous-control.scss index 77fbb0be..3afd6b36 100644 --- a/src/scss/next-previous-control.scss +++ b/src/scss/next-previous-control.scss @@ -1,5 +1,4 @@ @use './button.scss'; -@use 'locked.scss'; :host { --advanced-camera-card-next-prev-size: 48px; @@ -24,6 +23,17 @@ right: var(--advanced-camera-card-right-position); } +// Dim while locked. Deliberately applied to `.controls` rather than `:host` +// (as the shared `locked.scss` does): `opacity` on the host would create a +// stacking context that traps `.controls`'s `z-index`, dropping the control +// behind the media whenever it is DOM-ordered before it (the left control). +// `.controls` is already positioned and z-indexed, so dimming it here leaves +// its stacking context intact. +:host([locked]) .controls { + opacity: 0.4; + pointer-events: none; +} + .controls.icons { top: calc(50% - (var(--advanced-camera-card-next-prev-size) / 2)); } diff --git a/src/scss/notification-popup.scss b/src/scss/notification-popup.scss index c02e9530..5950568e 100644 --- a/src/scss/notification-popup.scss +++ b/src/scss/notification-popup.scss @@ -1,4 +1,5 @@ @use './z-index.scss' as *; +@use './pop-animation.scss' as *; @use './notification-common.scss'; :host { @@ -62,45 +63,11 @@ pointer-events: auto; - // Entry animation (auto-plays on render) - animation: slideUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; + @include pop-in; } -// Exit animation .notification.exiting { - animation: slideDown 0.25s ease-out forwards; -} - -@keyframes slideUp { - 0% { - opacity: 0; - transform: translateY(20px) scale(0.95); - } - 60% { - opacity: 1; - transform: translateY(-6px) scale(1.02); - } - 80% { - transform: translateY(3px) scale(0.98); - } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes slideDown { - 0% { - opacity: 1; - transform: translateY(0) scale(1); - } - 20% { - transform: translateY(-4px) scale(1.02); - } - 100% { - opacity: 0; - transform: translateY(20px) scale(0.95); - } + @include pop-out; } .controls { diff --git a/src/scss/pop-animation.scss b/src/scss/pop-animation.scss new file mode 100644 index 00000000..e4db9395 --- /dev/null +++ b/src/scss/pop-animation.scss @@ -0,0 +1,46 @@ +// Shared "pop" enter/exit animation for overlays. +// +// `@include pop-in` auto-plays an entrance on render. `@include pop-out` — +// typically guarded by an `.exiting` class — plays the matching exit; it is +// named `pop-out` so an `animationend` handler can detect exit completion and +// unmount the element. + +@mixin pop-in { + animation: pop-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; +} + +@mixin pop-out { + animation: pop-out 0.25s ease-out forwards; +} + +@keyframes pop-in { + 0% { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + 60% { + opacity: 1; + transform: translateY(-6px) scale(1.02); + } + 80% { + transform: translateY(3px) scale(0.98); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes pop-out { + 0% { + opacity: 1; + transform: translateY(0) scale(1); + } + 20% { + transform: translateY(-4px) scale(1.02); + } + 100% { + opacity: 0; + transform: translateY(20px) scale(0.95); + } +} diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss index ff25213e..9c55bd4d 100644 --- a/src/scss/themes/base.scss +++ b/src/scss/themes/base.scss @@ -65,6 +65,20 @@ // elements render as pills/capsules rather than ovals. --advanced-camera-card-button-border-radius: 100vmax; + /*************** + * Call controls + ***************/ + + // The background of the call controls. + --advanced-camera-card-call-controls-background: var( + --advanced-camera-card-control-background-transparent + ); + + // The color of the call controls end-call (critical) button. + --advanced-camera-card-call-controls-critical-color: var( + --advanced-camera-card-warning-color + ); + /****** * Menu ******/ @@ -234,6 +248,13 @@ --advanced-camera-card-trigger-border-color: var(--advanced-camera-card-warning-color); --advanced-camera-card-trigger-border-color-base: unset; + /************** + * Transmitting + **************/ + --advanced-camera-card-transmitting-border-color: var( + --advanced-camera-card-warning-color + ); + /***** * Grid ******/ diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index 6b085dcc..91f88bc7 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -4,8 +4,13 @@ box-sizing: border-box; gap: 2px; - // Ensure control icons are relative to the thumbnail. + // Position control icons relative to the thumbnail, and `isolation: isolate` + // to keep their `z-index` scoped here. Without a stacking context an + // unhovered gallery tile (which has no `transform`) lets its feature icons' + // z-index leak into the card's stacking context and paint over the `elements` + // overlay. position: relative; + isolation: isolate; overflow: hidden; transition: transform 0.2s linear; diff --git a/src/scss/z-index.scss b/src/scss/z-index.scss index 1c34617e..f22014cf 100644 --- a/src/scss/z-index.scss +++ b/src/scss/z-index.scss @@ -18,3 +18,11 @@ $z-index-menu: 4; $z-index-notification: 4; $z-index-drawer: 3; $z-index-status-bar: 2; + +// The call controls overlay sits above the live media (a peer of the status +// bar) but below the menu, drawer and other card chrome. +$z-index-call-controls: 2; + +// Picture elements overlay every view's content (which paints at z-index +// `auto`), so they always win against media/gallery/thumbnail surfaces. +$z-index-elements: 1; diff --git a/src/utils/action.ts b/src/utils/action.ts index dd316b51..20e4521f 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -1,5 +1,7 @@ import { CardActionsAPI } from '../card-controller/types.js'; import { ZoomSettingsBase } from '../components-lib/zoom/types.js'; +import { CallEndActionConfig } from '../config/schema/actions/custom/call-end.js'; +import { CallStartActionConfig } from '../config/schema/actions/custom/call-start.js'; import { CameraSelectActionConfig } from '../config/schema/actions/custom/camera-select.js'; import { DisplayModeActionConfig } from '../config/schema/actions/custom/display-mode.js'; import { @@ -272,6 +274,30 @@ export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig }; } +export function createCallStartAction( + camera?: string, + stream?: string, + options?: { + cardID?: string; + }, +): CallStartActionConfig { + return { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_start', + ...(camera && { camera }), + ...(stream && { stream }), + ...(options?.cardID && { card_id: options.cardID }), + }; +} + +export function createCallEndAction(options?: { cardID?: string }): CallEndActionConfig { + return { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_end', + ...(options?.cardID && { card_id: options.cardID }), + }; +} + export function createNotificationAction( notification: Notification, options?: { diff --git a/src/utils/animation.ts b/src/utils/animation.ts new file mode 100644 index 00000000..3b31a66a --- /dev/null +++ b/src/utils/animation.ts @@ -0,0 +1,18 @@ +import { POP_OUT_ANIMATION_NAME } from '../const.js'; + +/** + * Whether `ev` marks the end of a pop-out (exit) animation on the element the + * handler is bound to. + * + * `animationend` bubbles and `pop-out` is a shared keyframe name, so an event + * originating on a descendant that uses the same animation is excluded by + * requiring the animation to have run on the listening element itself. + */ +export function hasPopOutAnimationEnded( + // Only the fields actually read are required, rather than a full + // `AnimationEvent`. jsdom has no `AnimationEvent` constructor, so this lets + // tests pass a plain object instead of mocking the event. + ev: Pick, +): boolean { + return ev.target === ev.currentTarget && ev.animationName === POP_OUT_ANIMATION_NAME; +} diff --git a/src/utils/ptz.ts b/src/utils/ptz.ts index a707aa3c..0b4c83ff 100644 --- a/src/utils/ptz.ts +++ b/src/utils/ptz.ts @@ -1,9 +1,9 @@ import { CameraManager } from '../camera-manager/manager'; import { PTZAction } from '../config/schema/actions/custom/ptz'; import { PTZCapabilities } from '../types'; +import { getStreamCameraID } from '../view/substream'; import { getViewTargetID } from '../view/target-id'; import { View } from '../view/view'; -import { getStreamCameraID } from './substream'; export type PTZType = 'digital' | 'ptz'; interface PTZTarget { diff --git a/src/utils/substream.ts b/src/utils/substream.ts deleted file mode 100644 index 65d3a90d..00000000 --- a/src/utils/substream.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { View } from '../view/view'; - -/** - * Get the effective camera ID for streaming, considering substream overrides. - * Returns null if the view has no camera. - */ -export const getStreamCameraID = ( - view: View, - cameraID?: string | null, -): string | null => { - const baseCameraID = cameraID ?? view.camera; - if (!baseCameraID) { - return null; - } - return view.context?.live?.overrides?.get(baseCameraID) ?? baseCameraID; -}; - -export const hasSubstream = (view: View): boolean => { - if (!view.camera) { - return false; - } - return getStreamCameraID(view) !== view.camera; -}; - -export const setSubstream = (view: View, substreamID: string): void => { - if (!view.camera) { - return; - } - const overrides: Map = view.context?.live?.overrides ?? new Map(); - overrides.set(view.camera, substreamID); - view.mergeInContext({ - live: { overrides: overrides }, - }); -}; - -export const removeSubstream = (view: View): void => { - if (!view.camera) { - return; - } - const overrides: Map | undefined = view.context?.live?.overrides; - if (overrides && overrides.has(view.camera)) { - view.context?.live?.overrides?.delete(view.camera); - } -}; diff --git a/src/view/substream.ts b/src/view/substream.ts new file mode 100644 index 00000000..72b9b65a --- /dev/null +++ b/src/view/substream.ts @@ -0,0 +1,22 @@ +import { View } from './view'; + +// Resolves the engaged stream for a camera: its substream override if one is +// set in `live.overrides`, otherwise the camera itself. `cameraID` defaults to +// the selected camera. The write path is `SubstreamViewModifier`. +export const getStreamCameraID = ( + view: View, + cameraID?: string | null, +): string | null => { + const baseCameraID = cameraID ?? view.camera; + if (!baseCameraID) { + return null; + } + return view.context?.live?.overrides?.get(baseCameraID) ?? baseCameraID; +}; + +export const hasSubstream = (view: View): boolean => { + if (!view.camera) { + return false; + } + return getStreamCameraID(view) !== view.camera; +}; diff --git a/tests/card-controller/actions/actions/call-end.test.ts b/tests/card-controller/actions/actions/call-end.test.ts new file mode 100644 index 00000000..02ae8f2a --- /dev/null +++ b/tests/card-controller/actions/actions/call-end.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest'; +import { CallEndAction } from '../../../../src/card-controller/actions/actions/call-end'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle call_end action', async () => { + const api = createCardAPI(); + const action = new CallEndAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_end', + }, + ); + + await action.execute(api); + + expect(api.getCallManager().end).toBeCalled(); +}); diff --git a/tests/card-controller/actions/actions/call-start.test.ts b/tests/card-controller/actions/actions/call-start.test.ts new file mode 100644 index 00000000..837db4c5 --- /dev/null +++ b/tests/card-controller/actions/actions/call-start.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from 'vitest'; +import { CallStartAction } from '../../../../src/card-controller/actions/actions/call-start'; +import { createCardAPI } from '../../../test-utils'; + +it('should handle call_start action without a camera or stream', async () => { + const api = createCardAPI(); + const action = new CallStartAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_start', + }, + ); + + await action.execute(api); + + expect(api.getCallManager().start).toBeCalledWith(undefined, undefined); +}); + +it('should handle call_start action with a camera and stream', async () => { + const api = createCardAPI(); + const action = new CallStartAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_start', + camera: 'camera.front', + stream: 'camera.front_doorbell', + }, + ); + + await action.execute(api); + + expect(api.getCallManager().start).toBeCalledWith( + 'camera.front', + 'camera.front_doorbell', + ); +}); diff --git a/tests/card-controller/actions/actions/substream-off.test.ts b/tests/card-controller/actions/actions/substream-off.test.ts index 37550519..559e2f5d 100644 --- a/tests/card-controller/actions/actions/substream-off.test.ts +++ b/tests/card-controller/actions/actions/substream-off.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest'; import { SubstreamOffAction } from '../../../../src/card-controller/actions/actions/substream-off'; +import { SubstreamViewModifier } from '../../../../src/card-controller/view/modifiers/substream'; import { createCardAPI } from '../../../test-utils'; -import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off'; it('should handle live_substream_off action', async () => { const api = createCardAPI(); @@ -16,6 +16,6 @@ it('should handle live_substream_off action', async () => { await action.execute(api); expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - modifiers: [expect.any(SubstreamOffViewModifier)], + modifiers: [expect.any(SubstreamViewModifier)], }); }); diff --git a/tests/card-controller/actions/actions/substream-on.test.ts b/tests/card-controller/actions/actions/substream-on.test.ts index 9369bc34..1bc32c35 100644 --- a/tests/card-controller/actions/actions/substream-on.test.ts +++ b/tests/card-controller/actions/actions/substream-on.test.ts @@ -1,11 +1,20 @@ -import { expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { CameraManagerStore } from '../../../../src/camera-manager/store'; import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on'; -import { createCardAPI } from '../../../test-utils'; -import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on'; +import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers'; +import { getStreamCameraID } from '../../../../src/view/substream'; +import { View } from '../../../../src/view/view'; +import { + createCameraConfig, + createCameraManager, + createCapabilities, + createCardAPI, + createStore, + createView, +} from '../../../test-utils'; -it('should handle live_substream_on action', async () => { - const api = createCardAPI(); - const action = new SubstreamOnAction( +const createAction = (): SubstreamOnAction => + new SubstreamOnAction( {}, { action: 'fire-dom-event', @@ -13,9 +22,88 @@ it('should handle live_substream_on action', async () => { }, ); - await action.execute(api); +// A store where `camera.office` has one substream dependency, `camera.kitchen`. +const createStoreWithSubstreams = (): CameraManagerStore => + createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, substream: true }), + config: createCameraConfig({ dependencies: { all_cameras: true } }), + }, + { + cameraID: 'camera.kitchen', + capabilities: createCapabilities({ substream: true }), + }, + ]); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - modifiers: [expect.any(SubstreamOnViewModifier)], +// Runs the on-action for `view`, applies the modifier it produces (via the real +// `applyViewModifiers`), and returns the resulting engaged stream. +const getStreamAfterSubstreamOn = async ( + view: View, + store: CameraManagerStore = createStoreWithSubstreams(), +): Promise => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); + vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); + + await createAction().execute(api); + + const params = vi.mocked(api.getViewManager().setViewByParameters).mock.calls[0]?.[0]; + applyViewModifiers(view, params?.modifiers); + return getStreamCameraID(view); +}; + +describe('SubstreamOnAction', () => { + it('should advance to the next dependency', async () => { + expect( + await getStreamAfterSubstreamOn( + createView({ view: 'live', camera: 'camera.office' }), + ), + ).toBe('camera.kitchen'); + }); + + it('should wrap back to the parent camera', async () => { + const view = createView({ + view: 'live', + camera: 'camera.office', + context: { + live: { overrides: new Map([['camera.office', 'camera.kitchen']]) }, + }, + }); + + expect(await getStreamAfterSubstreamOn(view)).toBe('camera.office'); + }); + + it('should treat a malformed override as the start of the cycle', async () => { + const view = createView({ + view: 'live', + camera: 'camera.office', + context: { + live: { overrides: new Map([['camera.office', 'NOT_A_REAL_CAMERA']]) }, + }, + }); + + expect(await getStreamAfterSubstreamOn(view)).toBe('camera.office'); + }); + + it('should engage no substream when there are no usable dependencies', async () => { + const view = createView({ view: 'live', camera: 'camera.office' }); + + expect(await getStreamAfterSubstreamOn(view, createStore())).toBe('camera.office'); + }); + + it('should engage no substream when the view has no camera', async () => { + expect( + await getStreamAfterSubstreamOn(createView({ view: 'live', camera: null })), + ).toBeNull(); + }); + + it('should do nothing without a view', async () => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(null); + + await createAction().execute(api); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); }); diff --git a/tests/card-controller/actions/actions/substream-select.test.ts b/tests/card-controller/actions/actions/substream-select.test.ts index 0d320ec3..e17a2f37 100644 --- a/tests/card-controller/actions/actions/substream-select.test.ts +++ b/tests/card-controller/actions/actions/substream-select.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest'; import { SubstreamSelectAction } from '../../../../src/card-controller/actions/actions/substream-select'; +import { SubstreamViewModifier } from '../../../../src/card-controller/view/modifiers/substream'; import { createCardAPI } from '../../../test-utils'; -import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select'; it('should handle live_substream_select action', async () => { const api = createCardAPI(); @@ -16,9 +16,7 @@ it('should handle live_substream_select action', async () => { await action.execute(api); - expect(api.getViewManager().setViewByParameters).toBeCalledWith( - expect.objectContaining({ - modifiers: expect.arrayContaining([expect.any(SubstreamSelectViewModifier)]), - }), - ); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + }); }); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index 2a48c7fe..3ec70bb2 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; +import { CallEndAction } from '../../../src/card-controller/actions/actions/call-end'; import { CallServiceAction } from '../../../src/card-controller/actions/actions/call-service'; +import { CallStartAction } from '../../../src/card-controller/actions/actions/call-start'; import { CameraSelectAction } from '../../../src/card-controller/actions/actions/camera-select'; import { CameraUIAction } from '../../../src/card-controller/actions/actions/camera-ui'; import { CustomAction } from '../../../src/card-controller/actions/actions/custom'; @@ -87,6 +89,8 @@ describe('ActionFactory', () => { describe('custom actions', () => { it.each([ + [{ advanced_camera_card_action: 'call_end' as const }, CallEndAction], + [{ advanced_camera_card_action: 'call_start' as const }, CallStartAction], [{ advanced_camera_card_action: 'camera_select' as const }, CameraSelectAction], [{ advanced_camera_card_action: 'camera_ui' as const }, CameraUIAction], [{ advanced_camera_card_action: 'clip' as const }, ViewAction], diff --git a/tests/card-controller/call/manager.test.ts b/tests/card-controller/call/manager.test.ts new file mode 100644 index 00000000..60281162 --- /dev/null +++ b/tests/card-controller/call/manager.test.ts @@ -0,0 +1,735 @@ +import { assert, describe, expect, it, vi } from 'vitest'; +import { CameraManagerStore } from '../../../src/camera-manager/store'; +import { CallManager } from '../../../src/card-controller/call/manager'; +import { CardController } from '../../../src/card-controller/controller'; +import { SubstreamViewModifier } from '../../../src/card-controller/view/modifiers/substream'; +import { ConditionStateChange } from '../../../src/conditions/types'; +import { View } from '../../../src/view/view'; +import { + createCameraConfig, + createCameraManager, + createCapabilities, + createCardAPI, + createStore, + createView, +} from '../../test-utils'; + +// A store with a single 2-way-audio-capable camera. +const createCallableStore = (cameraID = 'camera.office'): CameraManagerStore => + createStore([ + { + cameraID, + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + ]); + +const createAPI = (options?: { + view?: View | null; + store?: CameraManagerStore; + microphoneSupported?: boolean; + microphoneForbidden?: boolean; + microphoneConnected?: boolean; +}): CardController => { + const api = createCardAPI(); + vi.mocked(api.getViewManager().getView).mockReturnValue(options?.view ?? null); + vi.mocked(api.getCameraManager).mockReturnValue( + createCameraManager(options?.store ?? createCallableStore()), + ); + vi.mocked(api.getMicrophoneManager().isSupported).mockReturnValue( + options?.microphoneSupported ?? true, + ); + vi.mocked(api.getMicrophoneManager().isForbidden).mockReturnValue( + options?.microphoneForbidden ?? false, + ); + vi.mocked(api.getMicrophoneManager().isConnected).mockReturnValue( + options?.microphoneConnected ?? true, + ); + return api; +}; + +// The condition-state listener a CallManager registers in its constructor. +const getConditionStateListener = ( + api: CardController, +): ((change: ConditionStateChange) => void) => { + const listener = vi.mocked(api.getConditionStateManager().addListener).mock + .calls[0]?.[0]; + assert(listener); + return listener; +}; + +describe('isActive', () => { + it('should report inactive before a call starts', () => { + expect(new CallManager(createCardAPI()).isActive()).toBe(false); + }); + + it('should report active during a call', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + + await manager.start(); + + expect(manager.isActive()).toBe(true); + // The call runs on the parent camera's own stream, so callCameraID is + // absent. + expect(manager.getCall()).toEqual({ + cameraID: 'camera.office', + previousView: expect.any(View), + }); + expect(manager.getCall()?.previousView?.view).toBe('live'); + }); +}); + +describe('start', () => { + it('should do nothing without a view camera', async () => { + const api = createAPI({ view: createView({ camera: null }) }); + + await new CallManager(api).start(); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('should do nothing when already active for the camera', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + + await manager.start(); + vi.mocked(api.getViewManager().setViewByParameters).mockClear(); + await manager.start(); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('should start a call on the selected camera', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + + await new CallManager(api).start(); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true }); + }); + + it('should navigate to the live view when started from elsewhere', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office', view: 'clips' }), + }); + + await new CallManager(api).start(); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + params: { view: 'live', camera: 'camera.office' }, + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + }); + + it('should evolve the current view without params when already in live', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office', view: 'live' }), + }); + + await new CallManager(api).start(); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + expect(api.getViewManager().setViewByParameters).not.toBeCalledWith( + expect.objectContaining({ params: expect.anything() }), + ); + }); + + it('should record the view present when the call started', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office', view: 'clips' }), + }); + const manager = new CallManager(api); + + await manager.start(); + + const call = manager.getCall(); + expect(call?.previousView?.view).toBe('clips'); + expect(call?.previousView?.camera).toBe('camera.office'); + // Query results are dropped so they are re-fetched fresh on restore. + expect(call?.previousView?.queryResults).toBeNull(); + }); + + it('should record the live view when the call starts from live', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office', view: 'live' }), + }); + const manager = new CallManager(api); + + await manager.start(); + + const call = manager.getCall(); + expect(call?.previousView?.view).toBe('live'); + expect(call?.previousView?.camera).toBe('camera.office'); + }); + + it('should keep the original pre-call view when a call supersedes another', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office', view: 'clips' }), + store: createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.garage', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + ]), + }); + const manager = new CallManager(api); + + await manager.start(); + await manager.start('camera.garage'); + + const call = manager.getCall(); + expect(call?.cameraID).toBe('camera.garage'); + expect(call?.previousView?.view).toBe('clips'); + expect(call?.previousView?.camera).toBe('camera.office'); + }); + + it('should start a call from a non-camera view when a camera is explicit', async () => { + const api = createAPI({ + view: createView({ camera: null, view: 'folder' }), + store: createCallableStore('camera.office'), + }); + const manager = new CallManager(api); + + await manager.start('camera.office'); + + const call = manager.getCall(); + expect(call?.cameraID).toBe('camera.office'); + expect(call?.previousView?.view).toBe('folder'); + expect(call?.previousView?.camera).toBeNull(); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + params: { view: 'live', camera: 'camera.office' }, + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + }); + + it('should start the call on an explicit camera and navigate there', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.garage', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + ]), + }); + const manager = new CallManager(api); + + await manager.start('camera.garage'); + + const call = manager.getCall(); + expect(call?.cameraID).toBe('camera.garage'); + expect(call?.previousView?.view).toBe('live'); + expect(call?.previousView?.camera).toBe('camera.office'); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + params: { view: 'live', camera: 'camera.garage' }, + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + }); + + it('should start a call on an explicit stream of the parent camera', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: createStore([ + { + cameraID: 'camera.office', + config: createCameraConfig({ + dependencies: { cameras: ['camera.doorbell'] }, + }), + capabilities: createCapabilities({ live: true }), + }, + { + cameraID: 'camera.doorbell', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + ]), + }); + const manager = new CallManager(api); + + await manager.start('camera.office', 'camera.doorbell'); + + const call = manager.getCall(); + expect(call?.cameraID).toBe('camera.office'); + expect(call?.callCameraID).toBe('camera.doorbell'); + expect(call?.previousView?.view).toBe('live'); + }); + + it('should abort when the requested camera is not a live camera', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + + await new CallManager(api).start('camera.unknown'); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getNotificationManager().setNotification).toBeCalled(); + }); + + it('should abort when the requested stream is not 2-way audio of the parent camera', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.unrelated', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + ]), + }); + + await new CallManager(api).start('camera.office', 'camera.unrelated'); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getNotificationManager().setNotification).toBeCalled(); + }); + + it('should supersede an active call on a different camera', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.garage', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + ]), + }); + const manager = new CallManager(api); + + await manager.start(); + await manager.start('camera.garage'); + + const call = manager.getCall(); + expect(call?.cameraID).toBe('camera.garage'); + expect(call?.previousView?.view).toBe('live'); + expect(call?.previousView?.camera).toBe('camera.office'); + expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false }); + expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true }); + }); + + it('should restart on the same camera with a different stream', async () => { + const api = createAPI({ + store: createStore([ + { + cameraID: 'camera.office', + config: createCameraConfig({ + dependencies: { cameras: ['camera.doorbell', 'camera.intercom'] }, + }), + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.doorbell', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.intercom', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + ]), + }); + + // The first call engages `camera.doorbell`; the view then reflects that + // substream, as it would at runtime when the second call_start arrives. + vi.mocked(api.getViewManager().getView) + .mockReturnValueOnce(createView({ camera: 'camera.office' })) + .mockReturnValue( + createView({ + camera: 'camera.office', + context: { + live: { overrides: new Map([['camera.office', 'camera.doorbell']]) }, + }, + }), + ); + const manager = new CallManager(api); + + await manager.start('camera.office', 'camera.doorbell'); + await manager.start('camera.office', 'camera.intercom'); + + // The restarted call carries the new stream; the recorded pre-call view + // keeps the genuine pre-call substream (none -- the camera's own stream), not the + // superseded call's engaged `camera.doorbell`. + const call = manager.getCall(); + expect(call?.cameraID).toBe('camera.office'); + expect(call?.callCameraID).toBe('camera.intercom'); + expect( + call?.previousView?.context?.live?.overrides?.get('camera.office'), + ).toBeUndefined(); + }); + + it('should abort when no stream supports 2-way audio', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: createStore([ + { cameraID: 'camera.office', capabilities: createCapabilities({ live: true }) }, + ]), + }); + + await new CallManager(api).start(); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getNotificationManager().setNotification).toBeCalled(); + }); + + it('should engage the active substream when it is call-capable', async () => { + const api = createAPI({ + view: createView({ + camera: 'camera.office', + context: { live: { overrides: new Map([['camera.office', 'camera.sub']]) } }, + }), + store: createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.sub', + capabilities: createCapabilities({ '2-way-audio': true }), + }, + ]), + }); + const manager = new CallManager(api); + + await manager.start(); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + // The pre-call substream is captured in the recorded view's context so it + // can be restored on call end. + expect( + manager.getCall()?.previousView?.context?.live?.overrides?.get('camera.office'), + ).toBe('camera.sub'); + }); + + it('should fall back to a call-capable dependency when the parent lacks audio', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + store: createStore([ + { + cameraID: 'camera.office', + config: createCameraConfig({ dependencies: { cameras: ['camera.doorbell'] } }), + capabilities: createCapabilities({ live: true }), + }, + { + cameraID: 'camera.doorbell', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + ]), + }); + + await new CallManager(api).start(); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + }); + + it('should abort when the microphone is unsupported', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneSupported: false, + }); + + await new CallManager(api).start(); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getNotificationManager().setNotification).toBeCalled(); + }); + + it('should abort when the microphone is forbidden', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneForbidden: true, + }); + + await new CallManager(api).start(); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getNotificationManager().setNotification).toBeCalled(); + }); + + it('should connect the microphone when not already connected', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneConnected: false, + }); + vi.mocked(api.getMicrophoneManager().connect).mockResolvedValue(); + + await new CallManager(api).start(); + + expect(api.getMicrophoneManager().connect).toBeCalled(); + expect(api.getViewManager().setViewByParameters).toBeCalled(); + }); + + it('should abort when connecting the microphone fails', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office' }), + microphoneConnected: false, + }); + vi.mocked(api.getMicrophoneManager().connect).mockRejectedValue(new Error()); + + await new CallManager(api).start(); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + expect(api.getNotificationManager().setNotification).toBeCalled(); + }); +}); + +describe('end', () => { + it('should do nothing when no call is active', () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + + new CallManager(api).end(); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('should end an active call', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + await manager.start(); + vi.mocked(api.getViewManager().setViewByParameters).mockClear(); + + manager.end(); + + expect(manager.isActive()).toBe(false); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false }); + }); + + it('should restore the pre-call substream when ending', async () => { + const api = createAPI({ + view: createView({ + camera: 'camera.office', + context: { live: { overrides: new Map([['camera.office', 'camera.sub']]) } }, + }), + store: createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.sub', + capabilities: createCapabilities({ '2-way-audio': true }), + }, + ]), + }); + const manager = new CallManager(api); + await manager.start(); + vi.mocked(api.getViewManager().setViewByParameters).mockClear(); + + manager.end(); + + // The recorded pre-call substream (`camera.sub`) is reinstated. + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + }); + + it('should return to the pre-call view on an explicit end', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office', view: 'clips' }), + }); + const manager = new CallManager(api); + await manager.start(); + + manager.end(); + + expect(api.getViewManager().setViewByParametersWithExistingQuery).toBeCalledWith({ + baseView: expect.any(View), + force: true, + }); + const restored = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery) + .mock.calls[0]?.[0]; + expect(restored?.baseView?.view).toBe('clips'); + expect(restored?.baseView?.camera).toBe('camera.office'); + }); + + it('should not navigate on an explicit end when the call started from live', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office', view: 'live' }), + }); + const manager = new CallManager(api); + await manager.start(); + vi.mocked(api.getViewManager().setViewByParameters).mockClear(); + + manager.end(); + + // No navigation: only the substream is undone. + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled(); + }); + + it('should return to a camera-less pre-call view on an explicit end', async () => { + const api = createAPI({ + view: createView({ camera: null, view: 'folder' }), + store: createCallableStore('camera.office'), + }); + const manager = new CallManager(api); + await manager.start('camera.office'); + + manager.end(); + + const restored = vi.mocked(api.getViewManager().setViewByParametersWithExistingQuery) + .mock.calls[0]?.[0]; + expect(restored?.baseView?.view).toBe('folder'); + expect(restored?.baseView?.camera).toBeNull(); + }); +}); + +describe('condition state changes', () => { + it('should end the call when the selected camera changes away', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + await manager.start(); + vi.mocked(api.getViewManager().setViewByParameters).mockClear(); + + getConditionStateListener(api)({ + old: { camera: 'camera.office', view: 'live' }, + change: { camera: 'camera.other' }, + new: { camera: 'camera.other', view: 'live' }, + }); + + expect(manager.isActive()).toBe(false); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + }); + + it('should not restore the pre-call view when the call auto-ends', async () => { + const api = createAPI({ + view: createView({ camera: 'camera.office', view: 'clips' }), + }); + const manager = new CallManager(api); + await manager.start(); + vi.mocked(api.getViewManager().setViewByParameters).mockClear(); + + getConditionStateListener(api)({ + old: { camera: 'camera.office', view: 'live' }, + change: { camera: 'camera.other' }, + new: { camera: 'camera.other', view: 'live' }, + }); + + expect(manager.isActive()).toBe(false); + expect(api.getViewManager().setViewByParametersWithExistingQuery).not.toBeCalled(); + }); + + it('should end the call when the view leaves live', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + await manager.start(); + vi.mocked(api.getViewManager().setViewByParameters).mockClear(); + + getConditionStateListener(api)({ + old: { camera: 'camera.office', view: 'live' }, + change: { view: 'clips' }, + new: { camera: 'camera.office', view: 'clips' }, + }); + + expect(manager.isActive()).toBe(false); + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + modifiers: [expect.any(SubstreamViewModifier)], + force: true, + }); + }); + + it('should keep the call when the selected camera is unchanged', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + await manager.start(); + + getConditionStateListener(api)({ + old: { camera: 'camera.office' }, + change: { view: 'live' }, + new: { camera: 'camera.office', view: 'live' }, + }); + + expect(manager.isActive()).toBe(true); + }); + + it('should no-op when no call is active', () => { + const api = createAPI(); + new CallManager(api); + + getConditionStateListener(api)({ + old: {}, + change: { camera: 'camera.other' }, + new: { camera: 'camera.other' }, + }); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + + it('should end the call when the substream changes away', async () => { + const api = createAPI({ view: createView({ camera: 'camera.office' }) }); + const manager = new CallManager(api); + await manager.start(); + + getConditionStateListener(api)({ + old: { camera: 'camera.office', view: 'live' }, + change: { substreamID: 'camera.sub' }, + new: { camera: 'camera.office', substreamID: 'camera.sub', view: 'live' }, + }); + + expect(manager.isActive()).toBe(false); + }); + + it('should keep the call when the substream is unchanged', async () => { + const api = createAPI({ + view: createView({ + camera: 'camera.office', + context: { live: { overrides: new Map([['camera.office', 'camera.sub']]) } }, + }), + store: createStore([ + { + cameraID: 'camera.office', + capabilities: createCapabilities({ live: true, '2-way-audio': true }), + }, + { + cameraID: 'camera.sub', + capabilities: createCapabilities({ '2-way-audio': true }), + }, + ]), + }); + const manager = new CallManager(api); + await manager.start(); + + getConditionStateListener(api)({ + old: { camera: 'camera.office', substreamID: 'camera.sub' }, + change: { view: 'live' }, + new: { camera: 'camera.office', substreamID: 'camera.sub', view: 'live' }, + }); + + expect(manager.isActive()).toBe(true); + }); +}); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index d73655cd..56a0771f 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CameraManager } from '../../src/camera-manager/manager'; import { ActionsManager } from '../../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../../src/card-controller/automations-manager'; +import { CallManager } from '../../src/card-controller/call/manager'; import { CameraURLManager } from '../../src/card-controller/camera-url-manager'; import { CardElementManager, @@ -39,6 +40,7 @@ import { ResolvedMediaCache } from '../../src/ha/resolved-media'; vi.mock('../../src/camera-manager/manager'); vi.mock('../../src/card-controller/actions/actions-manager'); vi.mock('../../src/card-controller/automations-manager'); +vi.mock('../../src/card-controller/call/manager'); vi.mock('../../src/card-controller/camera-url-manager'); vi.mock('../../src/card-controller/card-element-manager'); vi.mock('../../src/card-controller/config/config-manager'); @@ -115,6 +117,12 @@ describe('CardController', () => { ); }); + it('should return getCallManager', () => { + expect(createController().getCallManager()).toBe( + vi.mocked(CallManager).mock.instances[0], + ); + }); + it('should return getDefaultManager', () => { expect(createController().getDefaultManager()).toBe( vi.mocked(DefaultManager).mock.instances[0], diff --git a/tests/card-controller/lock/manager.test.ts b/tests/card-controller/lock/manager.test.ts index af810773..6228826f 100644 --- a/tests/card-controller/lock/manager.test.ts +++ b/tests/card-controller/lock/manager.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { CardController } from '../../../src/card-controller/controller'; import { LockManager } from '../../../src/card-controller/lock/manager'; import { createCameraAction, @@ -8,33 +9,50 @@ import { createMediaPlayerAction, createViewAction, } from '../../../src/utils/action'; -import { createCardAPI } from '../../test-utils'; +import { createCardAPI, createConfig } from '../../test-utils'; + +const setCallLock = (api: CardController, lock: boolean): void => { + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ live: { controls: { call: { lock } } } }), + ); +}; describe('LockManager', () => { it('should report unlocked when no lock source is active', () => { const api = createCardAPI(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false); + setCallLock(api, true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(false); expect(new LockManager(api).isLocked()).toBeFalsy(); }); - it('should report locked when the microphone is locking', () => { + it('should report locked when a call is active', () => { const api = createCardAPI(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + setCallLock(api, true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(true); expect(new LockManager(api).isLocked()).toBeTruthy(); }); + it('should report unlocked when a call is active but the lock is disabled', () => { + const api = createCardAPI(); + setCallLock(api, false); + vi.mocked(api.getCallManager().isActive).mockReturnValue(true); + + expect(new LockManager(api).isLocked()).toBeFalsy(); + }); + it('should reuse lock manager epoch until the lock state changes', () => { const api = createCardAPI(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false); + setCallLock(api, true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(false); const manager = new LockManager(api); const unlockedEpoch = manager.getEpoch(); expect(manager.getEpoch()).toBe(unlockedEpoch); expect(unlockedEpoch.locked).toBeFalsy(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(true); const lockedEpoch = manager.getEpoch(); expect(lockedEpoch).not.toBe(unlockedEpoch); expect(lockedEpoch.locked).toBeTruthy(); @@ -43,16 +61,18 @@ describe('LockManager', () => { it('should not filter actions when unlocked', () => { const api = createCardAPI(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false); + setCallLock(api, true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(false); const actions = [createGeneralAction('reload'), createLogAction('Allowed')]; expect(new LockManager(api).getAllowedActions(actions)).toBe(actions); }); - it('should reject microphone-session-disruptive actions when locked', () => { + it('should reject call-disruptive actions when locked', () => { const api = createCardAPI(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + setCallLock(api, true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(true); const manager = new LockManager(api); for (const action of [ @@ -72,7 +92,8 @@ describe('LockManager', () => { it('should preserve non-disruptive actions when locked', () => { const api = createCardAPI(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + setCallLock(api, true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(true); const manager = new LockManager(api); for (const action of [ @@ -93,7 +114,8 @@ describe('LockManager', () => { it('should preserve non-disruptive actions from a mixed action list when locked', () => { const api = createCardAPI(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + setCallLock(api, true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(true); const manager = new LockManager(api); const allowedAction = createLogAction('Allowed'); @@ -105,7 +127,8 @@ describe('LockManager', () => { it('should report whether all configured actions are blocked', () => { const api = createCardAPI(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(true); + setCallLock(api, true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(true); const manager = new LockManager(api); @@ -126,7 +149,8 @@ describe('LockManager', () => { it('should never report all-actions-blocked when unlocked', () => { const api = createCardAPI(); - vi.mocked(api.getMicrophoneManager().isLocking).mockReturnValue(false); + setCallLock(api, true); + vi.mocked(api.getCallManager().isActive).mockReturnValue(false); const manager = new LockManager(api); diff --git a/tests/card-controller/microphone-manager.test.ts b/tests/card-controller/microphone-manager.test.ts index dddede8a..0407cd72 100644 --- a/tests/card-controller/microphone-manager.test.ts +++ b/tests/card-controller/microphone-manager.test.ts @@ -244,82 +244,6 @@ describe('MicrophoneManager', () => { expect(api.getCardElementManager().update).toBeCalledTimes(1); }); - describe('isLocking', () => { - it('should not lock when muted', () => { - const api = createCardAPI(); - const manager = new MicrophoneManager(api); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - live: { - microphone: { - lock: true, - }, - }, - }), - ); - - expect(manager.isMuted()).toBeTruthy(); - expect(manager.isLocking()).toBeFalsy(); - }); - - it('should lock when unmuted and lock is enabled', async () => { - const api = createCardAPI(); - const manager = new MicrophoneManager(api); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - live: { - microphone: { - lock: true, - }, - }, - }), - ); - vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( - createMockStream(), - ); - - await manager.unmute(); - - expect(manager.isMuted()).toBeFalsy(); - expect(manager.isLocking()).toBeTruthy(); - }); - - it('should not lock when unmuted but lock is disabled', async () => { - const api = createCardAPI(); - const manager = new MicrophoneManager(api); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue( - createConfig({ - live: { - microphone: { - lock: false, - }, - }, - }), - ); - vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( - createMockStream(), - ); - - await manager.unmute(); - - expect(manager.isMuted()).toBeFalsy(); - expect(manager.isLocking()).toBeFalsy(); - }); - - it('should not lock when config is unavailable', async () => { - const api = createCardAPI(); - const manager = new MicrophoneManager(api); - vi.mocked(navigatorMock.mediaDevices.getUserMedia).mockResolvedValue( - createMockStream(), - ); - - await manager.unmute(); - - expect(manager.isMuted()).toBeFalsy(); - expect(manager.isLocking()).toBeFalsy(); - }); - }); - describe('should require initialization', async () => { it('should require when configured and supported', async () => { const api = createCardAPI(); diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index e1a2cd16..e0ce0207 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CardController } from '../../src/card-controller/controller'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; -import { SubstreamSelectViewModifier } from '../../src/card-controller/view/modifiers/substream-select'; +import { SubstreamViewModifier } from '../../src/card-controller/view/modifiers/substream'; import { createCardAPI, createConfig } from '../test-utils'; const setQueryString = (qs: string): void => { @@ -154,7 +154,7 @@ describe('QueryStringManager', () => { expect(manager.hasViewRelatedActionsToRun()).toBeFalsy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ - modifiers: [expect.any(SubstreamSelectViewModifier)], + modifiers: [expect.any(SubstreamViewModifier)], params: {}, }); @@ -250,7 +250,7 @@ describe('QueryStringManager', () => { params: { camera: 'camera.kitchen', }, - modifiers: [expect.any(SubstreamSelectViewModifier)], + modifiers: [expect.any(SubstreamViewModifier)], }); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); diff --git a/tests/card-controller/status-bar-item-manager.test.ts b/tests/card-controller/status-bar-item-manager.test.ts index 83554bd2..00a19bad 100644 --- a/tests/card-controller/status-bar-item-manager.test.ts +++ b/tests/card-controller/status-bar-item-manager.test.ts @@ -312,6 +312,7 @@ describe('StatusBarItemManager', () => { const items = manager.calculateItems({ statusConfig: { + auto_hide: [], position: 'bottom', style: 'popup', popup_seconds: 3, @@ -356,6 +357,7 @@ describe('StatusBarItemManager', () => { const items = manager.calculateItems({ statusConfig: { + auto_hide: [], position: 'bottom', style: 'popup', popup_seconds: 3, diff --git a/tests/card-controller/view/modifiers/substream-off.test.ts b/tests/card-controller/view/modifiers/substream-off.test.ts deleted file mode 100644 index d58d0fbf..00000000 --- a/tests/card-controller/view/modifiers/substream-off.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { expect, it } from 'vitest'; -import { createView } from '../../../test-utils'; -import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off'; -import { hasSubstream, setSubstream } from '../../../../src/utils/substream'; - -it('should turn off substream', () => { - const view = createView({ - view: 'live', - camera: 'camera', - displayMode: 'grid', - }); - - setSubstream(view, 'substream'); - expect(hasSubstream(view)).toBe(true); - - const modifier = new SubstreamOffViewModifier(); - modifier.modify(view); - - expect(hasSubstream(view)).toBe(false); -}); diff --git a/tests/card-controller/view/modifiers/substream-on.test.ts b/tests/card-controller/view/modifiers/substream-on.test.ts deleted file mode 100644 index c7b19f68..00000000 --- a/tests/card-controller/view/modifiers/substream-on.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { CardController } from '../../../../src/card-controller/controller'; -import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on'; -import { RawAdvancedCameraCardConfig } from '../../../../src/config/types'; -import { - getStreamCameraID, - hasSubstream, - setSubstream, -} from '../../../../src/utils/substream'; -import { - createCameraConfig, - createCameraManager, - createCapabilities, - createCardAPI, - createConfig, - createStore, - createView, -} from '../../../test-utils'; - -const createAPIWithSubstreams = ( - config?: RawAdvancedCameraCardConfig, -): CardController => { - const api = createCardAPI(); - const store = createStore([ - { - cameraID: 'camera.office', - capabilities: createCapabilities({ - live: true, - substream: true, - }), - config: createCameraConfig({ - dependencies: { - all_cameras: true, - }, - }), - }, - { - cameraID: 'camera.kitchen', - capabilities: createCapabilities({ - substream: true, - }), - }, - ]); - vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config)); - return api; -}; - -describe('should turn on substream', () => { - it('substream available', () => { - const view = createView({ - view: 'live', - camera: 'camera.office', - }); - - expect(hasSubstream(view)).toBe(false); - - const api = createAPIWithSubstreams(); - - const modifier = new SubstreamOnViewModifier(api); - modifier.modify(view); - - expect(hasSubstream(view)).toBe(true); - expect(getStreamCameraID(view)).toBe('camera.kitchen'); - - modifier.modify(view); - - expect(hasSubstream(view)).toBe(false); - expect(getStreamCameraID(view)).toBe('camera.office'); - }); - - it('malformed substream', () => { - const view = createView({ - view: 'live', - camera: 'camera.office', - }); - - const api = createAPIWithSubstreams(); - - setSubstream(view, 'NOT_A_REAL_CAMERA'); - - const modifier = new SubstreamOnViewModifier(api); - modifier.modify(view); - - expect(hasSubstream(view)).toBe(false); - expect(getStreamCameraID(view)).toBe('camera.office'); - }); - - it('substream unavailable', () => { - const view = createView({ - view: 'live', - camera: 'camera.office', - }); - - expect(hasSubstream(view)).toBe(false); - - const api = createCardAPI(); - const cameraManager = createCameraManager(createStore([])); - vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); - - const modifier = new SubstreamOnViewModifier(api); - modifier.modify(view); - - expect(hasSubstream(view)).toBe(false); - }); - - it('without camera', () => { - const view = createView({ - camera: null, - view: 'live', - }); - - const api = createCardAPI(); - const modifier = new SubstreamOnViewModifier(api); - modifier.modify(view); - - expect(hasSubstream(view)).toBe(false); - }); -}); diff --git a/tests/card-controller/view/modifiers/substream-select.test.ts b/tests/card-controller/view/modifiers/substream-select.test.ts deleted file mode 100644 index 7c1a4455..00000000 --- a/tests/card-controller/view/modifiers/substream-select.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect, it } from 'vitest'; -import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select'; -import { getStreamCameraID, hasSubstream } from '../../../../src/utils/substream'; -import { createView } from '../../../test-utils'; - -it('should select substream', () => { - const view = createView({ - view: 'live', - camera: 'camera.office', - }); - - expect(hasSubstream(view)).toBe(false); - - const modifier = new SubstreamSelectViewModifier('substream'); - modifier.modify(view); - - expect(hasSubstream(view)).toBe(true); - expect(getStreamCameraID(view)).toBe('substream'); -}); diff --git a/tests/card-controller/view/modifiers/substream.test.ts b/tests/card-controller/view/modifiers/substream.test.ts new file mode 100644 index 00000000..d659d7dd --- /dev/null +++ b/tests/card-controller/view/modifiers/substream.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { SubstreamViewModifier } from '../../../../src/card-controller/view/modifiers/substream'; +import { createView } from '../../../test-utils'; + +describe('SubstreamViewModifier', () => { + it('should write the override for the selected camera', () => { + const view = createView({ camera: 'camera' }); + + new SubstreamViewModifier('substream').modify(view); + + expect(view.context?.live?.overrides?.get('camera')).toBe('substream'); + }); + + it('should clear the selected camera override when no substream is given', () => { + const view = createView({ + camera: 'camera', + context: { live: { overrides: new Map([['camera', 'substream']]) } }, + }); + + new SubstreamViewModifier().modify(view); + + expect(view.context?.live?.overrides?.get('camera')).toBeUndefined(); + }); + + it('should write the override for an explicit camera', () => { + const view = createView({ + camera: 'camera', + context: { live: { overrides: new Map([['camera', 'substream']]) } }, + }); + + new SubstreamViewModifier('other-substream', 'other-camera').modify(view); + + expect(view.context?.live?.overrides?.get('other-camera')).toBe('other-substream'); + expect(view.context?.live?.overrides?.get('camera')).toBe('substream'); + }); + + it('should clear the override for an explicit camera', () => { + const view = createView({ + camera: 'camera', + context: { live: { overrides: new Map([['other-camera', 'other-substream']]) } }, + }); + + new SubstreamViewModifier(undefined, 'other-camera').modify(view); + + expect(view.context?.live?.overrides?.get('other-camera')).toBeUndefined(); + }); + + it('should no-op for a view without a camera', () => { + const view = createView({ camera: null }); + + new SubstreamViewModifier('substream').modify(view); + + expect(view.context).toBeNull(); + }); +}); diff --git a/tests/card-controller/view/view-manager.test.ts b/tests/card-controller/view/view-manager.test.ts index 0c0209dc..c88a48f2 100644 --- a/tests/card-controller/view/view-manager.test.ts +++ b/tests/card-controller/view/view-manager.test.ts @@ -51,10 +51,29 @@ describe('should act correctly when view is set', () => { camera: 'camera', displayMode: 'grid', targetID: 'camera', + substreamID: undefined, }); expect(api.getCardElementManager().update).toBeCalled(); }); + it('should set the engaged substream in condition state', () => { + const view = createView({ + view: 'live', + camera: 'camera', + context: { live: { overrides: new Map([['camera', 'substream']]) } }, + }); + + const factory = mock(); + factory.getViewDefault.mockReturnValue(view); + + const api = createInitializedCardAPI(); + new ViewManager(api, { viewFactory: factory }).setViewDefault(); + + expect(api.getConditionStateManager()?.setState).toBeCalledWith( + expect.objectContaining({ substreamID: 'substream' }), + ); + }); + it('should set view with minor changes without scroll', () => { const view_1 = createView({ view: 'live', @@ -305,6 +324,48 @@ it('should set view by parameters with existing query', async () => { expect(manager.getView()?.camera).toBe('camera'); }); +it('should set view by parameters with an explicitly provided existing query', async () => { + const viewFactory = mock(); + viewFactory.getViewByParameters.mockReturnValue(createView()); + + const viewQueryExecutor = mock(); + viewQueryExecutor.getExistingQueryModifiers.mockResolvedValue([]); + + const manager = new ViewManager(createInitializedCardAPI(), { + viewFactory: viewFactory, + viewQueryExecutor: viewQueryExecutor, + }); + + const query = new UnifiedQuery(); + await manager.setViewByParametersWithExistingQuery({ params: { query } }); + + // An explicitly-passed query is used as-is rather than the base view's. + expect(viewFactory.getViewByParameters).toBeCalledWith( + expect.objectContaining({ params: expect.objectContaining({ query }) }), + ); +}); + +it('should clear the query when an explicit null query is passed', async () => { + const viewFactory = mock(); + viewFactory.getViewByParameters.mockReturnValue(createView()); + + const viewQueryExecutor = mock(); + viewQueryExecutor.getExistingQueryModifiers.mockResolvedValue([]); + + const manager = new ViewManager(createInitializedCardAPI(), { + viewFactory: viewFactory, + viewQueryExecutor: viewQueryExecutor, + }); + + await manager.setViewByParametersWithExistingQuery({ params: { query: null } }); + + // An explicit `null` is respected (clears the query), not overridden by the + // base view's query. + expect(viewFactory.getViewByParameters).toBeCalledWith( + expect.objectContaining({ params: expect.objectContaining({ query: null }) }), + ); +}); + describe('should handle exceptions', () => { it('should retry with failSafe when no existing view in sync calls', () => { const viewFactory = mock(); diff --git a/tests/components-lib/auto-hide.test.ts b/tests/components-lib/auto-hide.test.ts new file mode 100644 index 00000000..9ec84055 --- /dev/null +++ b/tests/components-lib/auto-hide.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { isAutoHidden, resolveAutoHideState } from '../../src/components-lib/auto-hide'; + +describe('isAutoHidden', () => { + it('should not hide with an empty condition list', () => { + expect(isAutoHidden([], { call: true, casting: true })).toBe(false); + }); + + it('should hide when a configured condition is active', () => { + expect(isAutoHidden(['call'], { call: true, casting: false })).toBe(true); + }); + + it('should not hide when no configured condition is active', () => { + expect(isAutoHidden(['call'], { call: false, casting: true })).toBe(false); + }); + + it('should hide when any of multiple configured conditions is active', () => { + expect(isAutoHidden(['call', 'casting'], { call: false, casting: true })).toBe(true); + }); +}); + +describe('resolveAutoHideState', () => { + it('should resolve with the supplied call state', () => { + expect(resolveAutoHideState(true)).toEqual({ call: true, casting: false }); + }); + + it('should default the call state to false', () => { + expect(resolveAutoHideState()).toEqual({ call: false, casting: false }); + }); +}); diff --git a/tests/components-lib/live/microphone-actions-controller.test.ts b/tests/components-lib/live/microphone-actions-controller.test.ts index 0bd9d9ee..9699472d 100644 --- a/tests/components-lib/live/microphone-actions-controller.test.ts +++ b/tests/components-lib/live/microphone-actions-controller.test.ts @@ -294,6 +294,81 @@ describe('MicrophoneActionsController', () => { }); }); + describe('on call state change', () => { + it('should unmute on call start when call is a configured unmute condition', () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoUnmuteConditions: ['call' as const], + }); + + controller.setCallActive(false); + controller.setCallActive(true); + + expect(microphoneManager.unmute).toBeCalledTimes(1); + }); + + it('should unmute when the call is already active on first notification', () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoUnmuteConditions: ['call' as const], + }); + + // `setCallActive(true)` is the first call-state signal, with no preceding + // `false` -- as for a live view that mounts while a call is already + // active. The initial state must not be swallowed as a baseline. + controller.setCallActive(true); + + expect(microphoneManager.unmute).toBeCalledTimes(1); + }); + + it('should mute on call end when call is a configured mute condition', () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoMuteConditions: ['call' as const], + }); + + controller.setCallActive(true); + controller.setCallActive(false); + + expect(microphoneManager.mute).toBeCalledTimes(1); + }); + + it('should not act on the initial call state', () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoMuteConditions: ['call' as const], + autoUnmuteConditions: ['call' as const], + }); + + controller.setCallActive(false); + + expect(microphoneManager.mute).not.toBeCalled(); + expect(microphoneManager.unmute).not.toBeCalled(); + }); + + it('should not act on call start when call is not a configured condition', () => { + const microphoneManager = createMicrophoneManager(); + const controller = new MicrophoneActionsController(); + controller.setOptions({ + microphoneManager, + autoUnmuteConditions: [], + }); + + controller.setCallActive(false); + controller.setCallActive(true); + + expect(microphoneManager.unmute).not.toBeCalled(); + }); + }); + describe('lifecycle', () => { it('should be idempotent on setRoot for the same element', () => { const controller = new MicrophoneActionsController(); diff --git a/tests/components-lib/media-actions-controller.test.ts b/tests/components-lib/media-actions-controller.test.ts index 8c54eac8..f8866470 100644 --- a/tests/components-lib/media-actions-controller.test.ts +++ b/tests/components-lib/media-actions-controller.test.ts @@ -631,19 +631,15 @@ describe('MediaActionsController', () => { controller.setOptions({ autoUnmuteConditions: ['microphone' as const], playerSelector: 'video', - microphoneState: createMicrophoneState({ muted: true }), }); + controller.setMicrophoneState(createMicrophoneState({ muted: true })); const children = createPlayerSlideNodes(); controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - controller.setOptions({ - autoUnmuteConditions: ['microphone' as const], - playerSelector: 'video', - microphoneState: createMicrophoneState({ muted: false }), - }); + controller.setMicrophoneState(createMicrophoneState({ muted: false })); expect( (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, @@ -656,19 +652,15 @@ describe('MediaActionsController', () => { controller.setOptions({ autoMuteConditions: ['microphone' as const], playerSelector: 'video', - microphoneState: createMicrophoneState({ muted: false }), }); + controller.setMicrophoneState(createMicrophoneState({ muted: false })); const children = createPlayerSlideNodes(); controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - controller.setOptions({ - autoMuteConditions: ['microphone' as const], - playerSelector: 'video', - microphoneState: createMicrophoneState({ muted: true }), - }); + controller.setMicrophoneState(createMicrophoneState({ muted: true })); vi.runOnlyPendingTimers(); @@ -683,19 +675,15 @@ describe('MediaActionsController', () => { controller.setOptions({ autoMuteConditions: [], playerSelector: 'video', - microphoneState: createMicrophoneState({ muted: false }), }); + controller.setMicrophoneState(createMicrophoneState({ muted: false })); const children = createPlayerSlideNodes(); controller.setRoot(createParent({ children: children })); await controller.setTarget(0, true); - controller.setOptions({ - autoMuteConditions: ['microphone' as const], - playerSelector: 'video', - microphoneState: createMicrophoneState({ muted: true }), - }); + controller.setMicrophoneState(createMicrophoneState({ muted: true })); vi.runOnlyPendingTimers(); @@ -703,5 +691,192 @@ describe('MediaActionsController', () => { (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute, ).not.toBeCalled(); }); + + it('should not act on the initial microphone state', async () => { + const controller = new MediaActionsController(); + + controller.setOptions({ + autoUnmuteConditions: ['microphone' as const], + playerSelector: 'video', + }); + + const children = createPlayerSlideNodes(); + controller.setRoot(createParent({ children: children })); + await controller.setTarget(0, true); + + controller.setMicrophoneState(createMicrophoneState({ muted: false })); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).not.toBeCalled(); + }); + }); + + describe('should take action on call state changes', () => { + it('should unmute the target on call start', async () => { + const controller = new MediaActionsController(); + + controller.setOptions({ + autoUnmuteConditions: ['call' as const], + playerSelector: 'video', + }); + controller.setCallActive(false); + + const children = createPlayerSlideNodes(); + controller.setRoot(createParent({ children: children })); + + await controller.setTarget(0, true); + + controller.setCallActive(true); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalled(); + }); + + it('should mute the target on call end', async () => { + const controller = new MediaActionsController(); + + controller.setOptions({ + autoMuteConditions: ['call' as const], + playerSelector: 'video', + }); + controller.setCallActive(true); + + const children = createPlayerSlideNodes(); + controller.setRoot(createParent({ children: children })); + + await controller.setTarget(0, true); + + controller.setCallActive(false); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute, + ).toBeCalled(); + }); + + it('should not act on the initial call state', async () => { + const controller = new MediaActionsController(); + + controller.setOptions({ + autoMuteConditions: ['call' as const], + autoUnmuteConditions: ['call' as const], + playerSelector: 'video', + }); + + const children = createPlayerSlideNodes(); + controller.setRoot(createParent({ children: children })); + await controller.setTarget(0, true); + + controller.setCallActive(false); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.mute, + ).not.toBeCalled(); + }); + + it('should not act when call is not a configured condition', async () => { + const controller = new MediaActionsController(); + + controller.setOptions({ + autoUnmuteConditions: [], + playerSelector: 'video', + }); + controller.setCallActive(false); + + const children = createPlayerSlideNodes(); + controller.setRoot(createParent({ children: children })); + await controller.setTarget(0, true); + + controller.setCallActive(true); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).not.toBeCalled(); + }); + + it('should apply the call-start unmute when the target arrives after the call', async () => { + const controller = new MediaActionsController(); + + controller.setOptions({ + autoUnmuteConditions: ['call' as const], + playerSelector: 'video', + }); + + const children = createPlayerSlideNodes(); + controller.setRoot(createParent({ children: children })); + + // The call becomes active before any target is selected: with no + // target, the unmute cannot be applied yet. + controller.setCallActive(true); + await flushPromises(); + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).not.toBeCalled(); + + await controller.setTarget(0, true); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalled(); + }); + + it('should unmute when the call is already active on the first call-state signal', async () => { + const controller = new MediaActionsController(); + + controller.setOptions({ + autoUnmuteConditions: ['call' as const], + playerSelector: 'video', + }); + + const children = createPlayerSlideNodes(); + controller.setRoot(createParent({ children: children })); + await controller.setTarget(0, true); + + // `setCallActive(true)` is the first call-state signal, with no preceding + // `false` -- as for a carousel that loads while a call is already + // active. The initial state must not be swallowed as a baseline. + controller.setCallActive(true); + + expect( + (await getPlayer(children[0], 'video')?.getMediaPlayerController())?.unmute, + ).toBeCalled(); + }); + + it('should defer the call-start unmute until the media player is ready', async () => { + const controller = new MediaActionsController(); + + controller.setOptions({ + autoUnmuteConditions: ['call' as const], + playerSelector: 'video', + }); + controller.setCallActive(false); + + // A player whose media player controller is not ready on first request. + const mediaPlayerController = mock(); + const player = document.createElement('video'); + player['getMediaPlayerController'] = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValue(mediaPlayerController); + const child = createTestSlideNodes({ n: 1 })[0]; + child.appendChild(player as unknown as MediaPlayerElement); + + controller.setRoot(createParent({ children: [child] })); + await controller.setTarget(0, true); + + // The call starts while the player is still not ready: no unmute yet. + controller.setCallActive(true); + await flushPromises(); + expect(mediaPlayerController.unmute).not.toBeCalled(); + + // Once the media loads the deferred unmute is applied -- exactly once, + // so a later reload cannot clobber a manual mute made during the call. + player.dispatchEvent(new Event('advanced-camera-card:media:loaded')); + await flushPromises(); + player.dispatchEvent(new Event('advanced-camera-card:media:loaded')); + await flushPromises(); + expect(mediaPlayerController.unmute).toBeCalledTimes(1); + }); }); }); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 4f697def..531c488b 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -4,12 +4,13 @@ import { mock } from 'vitest-mock-extended'; import { Capabilities } from '../../src/camera-manager/capabilities.js'; import { CameraManager } from '../../src/camera-manager/manager.js'; import { CameraManagerCameraMetadata } from '../../src/camera-manager/types.js'; +import { CallManager } from '../../src/card-controller/call/manager.js'; import { FoldersManager } from '../../src/card-controller/folders/manager.js'; import { FolderQuery } from '../../src/card-controller/folders/types'; import { FullscreenManager } from '../../src/card-controller/fullscreen/fullscreen-manager.js'; import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js'; -import { PIPManager } from '../../src/card-controller/pip-manager.js'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager.js'; +import { PIPManager } from '../../src/card-controller/pip-manager.js'; import { ViewManager } from '../../src/card-controller/view/view-manager.js'; import { MenuButtonController, @@ -1167,9 +1168,166 @@ describe('MenuButtonController', () => { }); }); + describe('should have call button', () => { + it('with no view', () => { + const buttons = calculateButtons(controller, { + cameraManager: createCameraManager(), + view: null, + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'Start 2-way audio call' }), + ]), + ); + }); + + it('with a non-live view', () => { + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), + }, + ]), + ); + const buttons = calculateButtons(controller, { + cameraManager, + view: createView({ camera: 'camera-1', view: 'clips' }), + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'Start 2-way audio call' }), + ]), + ); + }); + + it('when no camera supports 2-way audio', () => { + const cameraManager = createCameraManager( + createStore([{ cameraID: 'camera-1', capabilities: createCapabilities() }]), + ); + const buttons = calculateButtons(controller, { cameraManager }); + + expect(buttons).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: 'Start 2-way audio call' }), + ]), + ); + }); + + it('with a single 2-way-audio target', () => { + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), + }, + ]), + ); + const buttons = calculateButtons(controller, { cameraManager }); + + expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, + icon: 'mdi:phone', + enabled: true, + priority: 50, + type: 'custom:advanced-camera-card-menu-icon', + title: 'Start 2-way audio call', + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_start', + }, + }); + }); + + it('with multiple 2-way-audio targets', () => { + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), + config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }), + }, + { + cameraID: 'camera-2', + capabilities: createCapabilities({ '2-way-audio': true }), + }, + ]), + ); + const buttons = calculateButtons(controller, { cameraManager }); + + expect(buttons).toContainEqual( + expect.objectContaining({ + icon: 'mdi:phone', + title: 'Start 2-way audio call', + type: 'custom:advanced-camera-card-menu-submenu', + items: [ + expect.objectContaining({ + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_start', + camera: 'camera-1', + }, + }), + expect.objectContaining({ + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_start', + camera: 'camera-1', + stream: 'camera-2', + }, + }), + ], + }), + ); + }); + + it('when a call is active', () => { + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), + }, + ]), + ); + const callManager = mock(); + vi.mocked(callManager.isActive).mockReturnValue(true); + const buttons = calculateButtons(controller, { + cameraManager, + callManager, + view: createView({ camera: 'camera-1' }), + }); + + expect(buttons).toContainEqual({ + alignment: 'matching', + state_color: true, + permanent: false, + icon: 'mdi:phone-hangup', + enabled: true, + priority: 50, + type: 'custom:advanced-camera-card-menu-icon', + title: 'End 2-way audio call', + style: { + animation: 'pulse 3s infinite', + color: 'var(--advanced-camera-card-menu-button-critical-color)', + }, + tap_action: { + action: 'fire-dom-event', + advanced_camera_card_action: 'call_end', + }, + }); + }); + }); + describe('should have microphone button', () => { it('when camera has 2-way-audio capability', () => { const microphoneManager = mock(); + const callManager = mock(); + vi.mocked(callManager.isActive).mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(false); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); @@ -1185,6 +1343,7 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, + callManager, }); expect(buttons).toContainEqual({ @@ -1213,6 +1372,8 @@ describe('MenuButtonController', () => { it('when camera does not have 2-way-audio capability', () => { const microphoneManager = mock(); + const callManager = mock(); + vi.mocked(callManager.isActive).mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(false); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); @@ -1223,6 +1384,34 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, + callManager, + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]), + ); + }); + + it('is not shown without an active call', () => { + const microphoneManager = mock(); + vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); + vi.mocked(microphoneManager.isMuted).mockReturnValue(false); + vi.mocked(microphoneManager.isSupported).mockReturnValue(true); + const callManager = mock(); + vi.mocked(callManager.isActive).mockReturnValue(false); + + const cameraManager = createCameraManager( + createStore([ + { + cameraID: 'camera-1', + capabilities: createCapabilities({ '2-way-audio': true }), + }, + ]), + ); + const buttons = calculateButtons(controller, { + cameraManager, + microphoneManager, + callManager, }); expect(buttons).not.toEqual( @@ -1232,6 +1421,8 @@ describe('MenuButtonController', () => { it('with forbidden microphone', () => { const microphoneManager = mock(); + const callManager = mock(); + vi.mocked(callManager.isActive).mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(true); const cameraManager = createCameraManager( @@ -1245,6 +1436,7 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, + callManager, }); expect(buttons).toContainEqual({ @@ -1262,6 +1454,8 @@ describe('MenuButtonController', () => { it('with muted microphone', () => { const microphoneManager = mock(); + const callManager = mock(); + vi.mocked(callManager.isActive).mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(true); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); @@ -1277,6 +1471,7 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, + callManager, }); expect(buttons).toContainEqual({ @@ -1302,6 +1497,8 @@ describe('MenuButtonController', () => { it('with unsupported microphone', () => { const microphoneManager = mock(); + const callManager = mock(); + vi.mocked(callManager.isActive).mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(true); vi.mocked(microphoneManager.isSupported).mockReturnValue(false); @@ -1317,6 +1514,7 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, + callManager, }); expect(buttons).toContainEqual({ @@ -1334,6 +1532,8 @@ describe('MenuButtonController', () => { it('with muted toggle type microphone', () => { const microphoneManager = mock(); + const callManager = mock(); + vi.mocked(callManager.isActive).mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(true); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); @@ -1349,6 +1549,7 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, + callManager, config: createConfig({ menu: { buttons: { microphone: { type: 'toggle' } } }, }), @@ -1373,6 +1574,8 @@ describe('MenuButtonController', () => { it('with unmuted toggle type microphone', () => { const microphoneManager = mock(); + const callManager = mock(); + vi.mocked(callManager.isActive).mockReturnValue(true); vi.mocked(microphoneManager.isForbidden).mockReturnValue(false); vi.mocked(microphoneManager.isMuted).mockReturnValue(false); vi.mocked(microphoneManager.isSupported).mockReturnValue(true); @@ -1388,6 +1591,7 @@ describe('MenuButtonController', () => { const buttons = calculateButtons(controller, { cameraManager, microphoneManager: microphoneManager, + callManager, config: createConfig({ menu: { buttons: { microphone: { type: 'toggle' } } }, }), diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 81636804..68667c7c 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -681,4 +681,37 @@ describe('MenuController', () => { }); }); }); + + describe('auto-hide', () => { + it('should not render before a config is set', () => { + const controller = new MenuController(createLitElement()); + expect(controller.shouldRender()).toBe(false); + }); + + it('should not render when the style is none', () => { + const controller = new MenuController(createLitElement()); + controller.setMenuConfig(createMenuConfig({ style: 'none' })); + expect(controller.shouldRender()).toBe(false); + }); + + it('should render with a config and no auto-hide state', () => { + const controller = new MenuController(createLitElement()); + controller.setMenuConfig(createMenuConfig({ auto_hide: ['call'] })); + expect(controller.shouldRender()).toBe(true); + }); + + it('should render when no auto-hide condition is active', () => { + const controller = new MenuController(createLitElement()); + controller.setMenuConfig(createMenuConfig({ auto_hide: ['call'] })); + controller.setAutoHideState({ call: false, casting: true }); + expect(controller.shouldRender()).toBe(true); + }); + + it('should not render when a configured auto-hide condition is active', () => { + const controller = new MenuController(createLitElement()); + controller.setMenuConfig(createMenuConfig({ auto_hide: ['call'] })); + controller.setAutoHideState({ call: true, casting: false }); + expect(controller.shouldRender()).toBe(false); + }); + }); }); diff --git a/tests/components-lib/status-bar-controller.test.ts b/tests/components-lib/status-bar-controller.test.ts index c4a7bb9d..6eaae4be 100644 --- a/tests/components-lib/status-bar-controller.test.ts +++ b/tests/components-lib/status-bar-controller.test.ts @@ -429,4 +429,35 @@ describe('StatusBarController', () => { ); }); }); + + describe('auto-hide', () => { + const sufficientItem = { + type: 'custom:advanced-camera-card-status-bar-string' as const, + string: 'Item', + sufficient: true, + }; + + it('should render with a config and no auto-hide state', () => { + const controller = new StatusBarController(createLitElement()); + controller.setConfig(createConfig({ auto_hide: ['call'] })); + controller.setItems([sufficientItem]); + expect(controller.shouldRender()).toBe(true); + }); + + it('should render when no auto-hide condition is active', () => { + const controller = new StatusBarController(createLitElement()); + controller.setConfig(createConfig({ auto_hide: ['call'] })); + controller.setItems([sufficientItem]); + controller.setAutoHideState({ call: false, casting: true }); + expect(controller.shouldRender()).toBe(true); + }); + + it('should not render when a configured auto-hide condition is active', () => { + const controller = new StatusBarController(createLitElement()); + controller.setConfig(createConfig({ auto_hide: ['call'] })); + controller.setItems([sufficientItem]); + controller.setAutoHideState({ call: true, casting: false }); + expect(controller.shouldRender()).toBe(false); + }); + }); }); diff --git a/tests/conditions/conditions-manager.test.ts b/tests/conditions/conditions-manager.test.ts index e35e8c73..ddfc5135 100644 --- a/tests/conditions/conditions-manager.test.ts +++ b/tests/conditions/conditions-manager.test.ts @@ -740,63 +740,6 @@ describe('ConditionsManager', () => { ...state, }; }; - it('empty', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'microphone' as const }], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - microphone: createMicrophoneState({ connected: true }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - microphone: createMicrophoneState({ connected: false }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ microphone: createMicrophoneState({ muted: true }) }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); - expect(manager.getEvaluation().result).toBeTruthy(); - }); - - it('connected is true', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'microphone' as const, connected: true }], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - microphone: createMicrophoneState({ connected: true }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - stateManager.setState({ - microphone: createMicrophoneState({ connected: false }), - }); - expect(manager.getEvaluation().result).toBeFalsy(); - }); - - it('connected is false', () => { - const stateManager = new ConditionStateManager(); - const manager = new ConditionsManager( - [{ condition: 'microphone' as const, connected: false }], - stateManager, - ); - - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - microphone: createMicrophoneState({ connected: true }), - }); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - microphone: createMicrophoneState({ connected: false }), - }); - expect(manager.getEvaluation().result).toBeTruthy(); - }); it('muted is true', () => { const stateManager = new ConditionStateManager(); @@ -825,26 +768,50 @@ describe('ConditionsManager', () => { stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); expect(manager.getEvaluation().result).toBeTruthy(); }); + }); - it('connected and muted', () => { + describe('with call condition', () => { + it('bare form defaults to call:true', () => { const stateManager = new ConditionStateManager(); const manager = new ConditionsManager( - [{ condition: 'microphone' as const, muted: false, connected: true }], + [{ condition: 'call' as const }], stateManager, ); expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ microphone: createMicrophoneState({ muted: true }) }); + stateManager.setState({ call: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ call: false }); expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ microphone: createMicrophoneState({ muted: false }) }); + }); + + it('call is true', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'call' as const, call: true }], + stateManager, + ); + expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - microphone: createMicrophoneState({ connected: false, muted: false }), - }); + stateManager.setState({ call: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ call: false }); expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ - microphone: createMicrophoneState({ connected: true, muted: false }), - }); + }); + + it('call is false', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'call' as const, call: false }], + stateManager, + ); + + // With no state.call published, the bare condition matches `false`, + // so `call: false` is satisfied initially. + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ call: true }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ call: false }); expect(manager.getEvaluation().result).toBeTruthy(); }); }); diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index 1db6fa0f..c48141f5 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -3846,5 +3846,210 @@ describe('should handle version specific upgrades', () => { postUpgradeChecks(config); }); }); + + describe('microphone.connected → call condition', () => { + it('rewrites connected:true to call:true in an automation', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [{ condition: 'microphone', connected: true }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'live', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0].conditions).toEqual([ + { condition: 'call', call: true }, + ]); + postUpgradeChecks(config); + }); + + it('rewrites connected:false to call:false', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [{ condition: 'microphone', connected: false }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'live', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0].conditions).toEqual([ + { condition: 'call', call: false }, + ]); + postUpgradeChecks(config); + }); + + it('leaves a microphone.muted only condition untouched', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [{ condition: 'microphone', muted: true }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'live', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeFalsy(); + expect(config.automations[0].conditions).toEqual([ + { condition: 'microphone', muted: true }, + ]); + postUpgradeChecks(config); + }); + + it('splits a condition with both connected and muted into an AND condition', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [{ condition: 'microphone', connected: true, muted: false }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'live', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0].conditions).toEqual([ + { + condition: 'and', + conditions: [ + { condition: 'call', call: true }, + { condition: 'microphone', muted: false }, + ], + }, + ]); + postUpgradeChecks(config); + }); + + it('migrates a microphone.connected nested under or/and/not', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [ + { + condition: 'or', + conditions: [ + { + condition: 'and', + conditions: [ + { + condition: 'not', + conditions: [{ condition: 'microphone', connected: true }], + }, + ], + }, + ], + }, + ], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'live', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.automations[0].conditions).toEqual([ + { + condition: 'or', + conditions: [ + { + condition: 'and', + conditions: [ + { + condition: 'not', + conditions: [{ condition: 'call', call: true }], + }, + ], + }, + ], + }, + ]); + postUpgradeChecks(config); + }); + + it('migrates conditions on elements and overrides', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + elements: [ + { + type: 'custom:advanced-camera-card-conditional', + conditions: [{ condition: 'microphone', connected: true }], + elements: [{ type: 'icon', icon: 'mdi:phone' }], + }, + ], + overrides: [ + { + conditions: [{ condition: 'microphone', connected: false }], + merge: {}, + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + expect(config.elements[0].conditions).toEqual([ + { condition: 'call', call: true }, + ]); + expect(config.overrides[0].conditions).toEqual([ + { condition: 'call', call: false }, + ]); + postUpgradeChecks(config); + }); + + it('is idempotent', () => { + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + automations: [ + { + conditions: [{ condition: 'microphone', connected: true }], + actions: [ + { + action: 'fire-dom-event', + advanced_camera_card_action: 'live', + }, + ], + }, + ], + }; + expect(upgradeConfig(config)).toBeTruthy(); + + // Running upgradeConfig again should not change anything. + expect(upgradeConfig(config)).toBeFalsy(); + expect(config.automations[0].conditions).toEqual([ + { condition: 'call', call: true }, + ]); + postUpgradeChecks(config); + }); + }); }); }); diff --git a/tests/config/profiles/casting.test.ts b/tests/config/profiles/casting.test.ts index de21e06b..ece865c9 100644 --- a/tests/config/profiles/casting.test.ts +++ b/tests/config/profiles/casting.test.ts @@ -17,7 +17,6 @@ it('should contain expected defaults', () => { 'menu.buttons.media_player.enabled': false, 'menu.buttons.mute.enabled': true, 'menu.buttons.play.enabled': true, - 'menu.style': 'none', }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index d0c4cedd..508980c3 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -85,13 +85,18 @@ describe('config defaults', () => { zoomable: true, }, live: { - auto_mute: ['unselected', 'hidden', 'microphone'], + auto_mute: ['unselected', 'hidden', 'microphone', 'call'], auto_pause: [], auto_play: ['selected', 'visible'], - auto_unmute: ['microphone'], + auto_unmute: ['microphone', 'call'], controls: { builtin: true, + call: { + button_size: 40, + lock: true, + }, next_previous: { + auto_hide: ['call', 'casting'], size: 48, style: 'chevrons', }, @@ -133,10 +138,9 @@ describe('config defaults', () => { lazy_unload: [], microphone: { always_connected: false, - auto_mute: [], + auto_mute: ['call'], auto_unmute: [], disconnect_seconds: 90, - lock: true, mute_after_microphone_mute_seconds: 60, }, preload: false, @@ -168,6 +172,7 @@ describe('config defaults', () => { controls: { builtin: true, next_previous: { + auto_hide: ['casting'], size: 48, style: 'thumbnails', }, @@ -212,8 +217,16 @@ describe('config defaults', () => { }, menu: { alignment: 'left', + auto_hide: ['call', 'casting'], button_size: 40, buttons: { + call: { + alignment: 'matching', + enabled: true, + permanent: false, + priority: 50, + state_color: true, + }, camera_ui: { alignment: 'matching', enabled: true, @@ -426,6 +439,7 @@ describe('config defaults', () => { }, }, status_bar: { + auto_hide: ['call', 'casting'], height: 40, items: { engine: { @@ -868,6 +882,7 @@ describe('config defaults', () => { it('should include all conditions', () => { const conditions = [ { condition: 'and', conditions: [{ condition: 'initialized' }] }, + { condition: 'call', call: true }, { condition: 'camera', cameras: ['camera.office'] }, { condition: 'config', paths: ['menu.style'] }, { condition: 'display_mode', display_mode: 'single' }, @@ -885,7 +900,7 @@ describe('config defaults', () => { state: 'down', }, { condition: 'media_loaded', media_loaded: true }, - { condition: 'microphone', connected: true, muted: true }, + { condition: 'microphone', muted: true }, { condition: 'not', conditions: [{ condition: 'initialized' }] }, { condition: 'numeric_state', diff --git a/tests/test-utils.ts b/tests/test-utils.ts index ddf5019e..4c5ed859 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -23,6 +23,7 @@ import { } from '../src/camera-manager/types'; import { ActionsManager } from '../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; +import { CallManager } from '../src/card-controller/call/manager'; import { CameraURLManager } from '../src/card-controller/camera-url-manager'; import { CardElementManager, @@ -688,6 +689,7 @@ export const createCardAPI = (): CardController => { api.getActionsManager.mockReturnValue(mock()); api.getAutomationsManager.mockReturnValue(mock()); + api.getCallManager.mockReturnValue(mock()); api.getDefaultManager.mockReturnValue(mock()); api.getCameraManager.mockReturnValue(mock()); api.getCameraURLManager.mockReturnValue(mock()); diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 7f9c157e..ae10eb10 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -3,6 +3,8 @@ import { mock } from 'vitest-mock-extended'; import { INTERNAL_CALLBACK_ACTION } from '../../src/config/schema/actions/custom/internal.js'; import { ActionConfig } from '../../src/config/schema/actions/types.js'; import { + createCallEndAction, + createCallStartAction, createCameraAction, createDisplayModeAction, createEffectAction, @@ -358,6 +360,46 @@ describe('createSetReviewAction', () => { }); }); +describe('createCallStartAction', () => { + it('should create call start action without a camera', () => { + expect(createCallStartAction()).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'call_start', + }); + }); + + it('should create call start action with a camera, stream and cardID', () => { + expect( + createCallStartAction('camera.front', 'camera.front_doorbell', { + cardID: 'card_id', + }), + ).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'call_start', + camera: 'camera.front', + stream: 'camera.front_doorbell', + card_id: 'card_id', + }); + }); +}); + +describe('createCallEndAction', () => { + it('should create call end action', () => { + expect(createCallEndAction()).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'call_end', + }); + }); + + it('should create call end action with a cardID', () => { + expect(createCallEndAction({ cardID: 'card_id' })).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'call_end', + card_id: 'card_id', + }); + }); +}); + describe('createNotificationAction', () => { it('should create notification action', () => { const notification = { body: { text: 'test' } }; diff --git a/tests/utils/animation.test.ts b/tests/utils/animation.test.ts new file mode 100644 index 00000000..7edd363e --- /dev/null +++ b/tests/utils/animation.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { hasPopOutAnimationEnded } from '../../src/utils/animation.js'; + +describe('hasPopOutAnimationEnded', () => { + const element = mock(); + + it('should return true when a pop-out animation ends on the bound element', () => { + expect( + hasPopOutAnimationEnded({ + target: element, + currentTarget: element, + animationName: 'pop-out', + }), + ).toBe(true); + }); + + it('should return false when the event bubbled from a descendant', () => { + expect( + hasPopOutAnimationEnded({ + target: mock(), + currentTarget: element, + animationName: 'pop-out', + }), + ).toBe(false); + }); + + it('should return false for a different animation', () => { + expect( + hasPopOutAnimationEnded({ + target: element, + currentTarget: element, + animationName: 'pop-in', + }), + ).toBe(false); + }); +}); diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts deleted file mode 100644 index 6fbf6eb1..00000000 --- a/tests/utils/substream.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getStreamCameraID, - hasSubstream, - removeSubstream, - setSubstream, -} from '../../src/utils/substream'; -import { View } from '../../src/view/view'; -import { createView } from '../test-utils'; - -describe('hasSubstream/getStreamCameraID', () => { - it('should detect substream', () => { - const view = createView({ - camera: 'camera', - context: { - live: { - overrides: new Map([['camera', 'camera2']]), - }, - }, - }); - expect(hasSubstream(view)).toBeTruthy(); - expect(getStreamCameraID(view)).toBe('camera2'); - }); - it('should not detect substream when absent', () => { - const view = createView({ - camera: 'camera', - }); - expect(hasSubstream(view)).toBeFalsy(); - expect(getStreamCameraID(view)).toBe('camera'); - }); - it('should not detect substream when main stream', () => { - const view = createView({ - camera: 'camera', - context: { - live: { - overrides: new Map([['camera', 'camera']]), - }, - }, - }); - expect(hasSubstream(view)).toBeFalsy(); - expect(getStreamCameraID(view)).toBe('camera'); - }); - describe('should respect cameraID override', () => { - it('should respect cameraID override when present in overrides', () => { - const view = createView({ - camera: 'camera', - context: { - live: { - overrides: new Map([ - ['camera', 'camera2'], - ['camera3', 'camera4'], - ]), - }, - }, - }); - expect(hasSubstream(view)).toBeTruthy(); - expect(getStreamCameraID(view, 'camera3')).toBe('camera4'); - }); - - it('should respect cameraID override when not present in overrides', () => { - const view = createView(); - expect(hasSubstream(view)).toBeFalsy(); - expect(getStreamCameraID(view, 'camera3')).toBe('camera3'); - }); - }); - - it('should correctly handle null cameras', () => { - expect(getStreamCameraID(createView({ camera: null }))).toBeNull(); - expect(hasSubstream(createView({ camera: null }))).toBeFalsy(); - }); -}); - -describe('setSubstream', () => { - it('should set substream', () => { - const view = createView({ camera: 'camera1' }); - setSubstream(view, 'substream1'); - expect(view.context?.live?.overrides?.get('camera1')).toBe('substream1'); - }); - - it('should return null without a camera', () => { - const view = createView({ camera: null }); - setSubstream(view, 'foo'); - expect(view.context).toBeNull(); - }); -}); - -describe('removeSubstream', () => { - it('should remove substream that exists', () => { - const view = new View({ - view: 'live', - camera: 'camera', - context: { - live: { - overrides: new Map([['camera', 'camera2']]), - }, - }, - }); - removeSubstream(view); - expect(view.context).toEqual({ - live: { - overrides: new Map(), - }, - }); - }); - - it('should not remove substream that does not exists', () => { - const view = new View({ - view: 'live', - camera: 'camera-has-no-overrides', - context: { - live: { - overrides: new Map([['camera', 'camera2']]), - }, - }, - }); - removeSubstream(view); - expect(view.context).toEqual({ - live: { - overrides: new Map([['camera', 'camera2']]), - }, - }); - }); - - it('should not remove substream without camera', () => { - const view = createView({ - camera: null, - }); - removeSubstream(view); - expect(view.context).toBeNull(); - }); -}); diff --git a/tests/view/substream.test.ts b/tests/view/substream.test.ts new file mode 100644 index 00000000..113ff119 --- /dev/null +++ b/tests/view/substream.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { getStreamCameraID, hasSubstream } from '../../src/view/substream'; +import { createView } from '../test-utils'; + +describe('getStreamCameraID / hasSubstream', () => { + it('should report a substream override', () => { + const view = createView({ + camera: 'camera', + context: { + live: { + overrides: new Map([['camera', 'camera2']]), + }, + }, + }); + expect(hasSubstream(view)).toBeTruthy(); + expect(getStreamCameraID(view)).toBe('camera2'); + }); + + it('should not report a substream when absent', () => { + const view = createView({ camera: 'camera' }); + expect(hasSubstream(view)).toBeFalsy(); + expect(getStreamCameraID(view)).toBe('camera'); + }); + + it('should not report a substream when the override points at the main stream', () => { + const view = createView({ + camera: 'camera', + context: { + live: { + overrides: new Map([['camera', 'camera']]), + }, + }, + }); + expect(hasSubstream(view)).toBeFalsy(); + expect(getStreamCameraID(view)).toBe('camera'); + }); + + describe('should respect explicit cameraID argument', () => { + it('when the cameraID has an override', () => { + const view = createView({ + camera: 'camera', + context: { + live: { + overrides: new Map([ + ['camera', 'camera2'], + ['camera3', 'camera4'], + ]), + }, + }, + }); + expect(hasSubstream(view)).toBeTruthy(); + expect(getStreamCameraID(view, 'camera3')).toBe('camera4'); + }); + + it('when the cameraID has no override', () => { + const view = createView(); + expect(hasSubstream(view)).toBeFalsy(); + expect(getStreamCameraID(view, 'camera3')).toBe('camera3'); + }); + }); + + it('should handle a null camera', () => { + expect(getStreamCameraID(createView({ camera: null }))).toBeNull(); + expect(hasSubstream(createView({ camera: null }))).toBeFalsy(); + }); +});