feat: Add 'call' support to improve 2-way audio experience (#2486)

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
```
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent bb061a1a55
commit abcba884e5
116 changed files with 3871 additions and 845 deletions
@@ -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
+27 -15
View File
@@ -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
+57 -31
View File
@@ -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
+7 -4
View File
@@ -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
+28 -17
View File
@@ -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
+11 -7
View File
@@ -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
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

+59
View File
@@ -0,0 +1,59 @@
@startuml
skinparam backgroundColor white
actor User
participant "Call" as Card
participant Microphone as Mic
actor Guest
opt <color:#43a047>**""live.microphone.always_connected""**</color> 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 <color:#43a047>**""call_start""**</color> action.
Needs a camera that supports 2-way audio
end note
opt <color:#43a047>**""live.microphone.always_connected""**</color> not enabled
Card -> Mic : Microphone connection attempted (muted)
note over Mic : Stays muted by default (<color:#43a047>**""live.microphone.auto_unmute""**</color>)
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 (<color:#43a047>**""live.auto_unmute""**</color>)
Menu, status bar & next/previous hide (<color:#43a047>**""auto_hide""**</color>)
Camera navigation is locked (<color:#43a047>**""live.controls.call.lock""**</color>)
end note
User -> Mic : Unmute to talk
note over Mic : List 'call' in <color:#43a047>**""live.microphone.auto_unmute""**</color> 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, <color:#43a047>**""call_end""**</color> action, or — when
<color:#43a047>**""live.controls.call.lock""**</color> is disabled — navigating away
end note
Card --> User : Inbound audio mutes (<color:#43a047>**""live.auto_mute""**</color>)
Card -> Mic : Microphone mutes (<color:#43a047>**""live.microphone.auto_mute""**</color>)
note over Mic
Disconnects after <color:#43a047>**""live.microphone.disconnect_seconds""**</color>
unless <color:#43a047>**""live.microphone.always_connected""**</color>
end note
@enduml
+64 -18
View File
@@ -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.
+1
View File
@@ -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 .",
+12 -4
View File
@@ -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,
@@ -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<CallEndActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getCallManager().end();
}
}
@@ -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<CallStartActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
await api.getCallManager().start(this._action.camera, this._action.stream);
}
}
@@ -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';
@@ -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<GeneralActionConfig> {
@@ -8,7 +8,7 @@ export class SubstreamOffAction extends AdvancedCameraCardAction<GeneralActionCo
await super.execute(api);
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamOffViewModifier()],
modifiers: [new SubstreamViewModifier()],
});
}
}
@@ -1,14 +1,48 @@
import { CameraManager } from '../../../camera-manager/manager';
import { SubstreamViewModifier } from '../../view/modifiers/substream';
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import { View } from '../../../view/view';
import { CardActionsAPI } from '../../types';
import { SubstreamOnViewModifier } from '../../view/modifiers/substream-on';
import { AdvancedCameraCardAction } from './base';
export class SubstreamOnAction extends AdvancedCameraCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
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];
}
}
@@ -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<SubstreamSelectActionConfig> {
@@ -8,7 +8,7 @@ export class SubstreamSelectAction extends AdvancedCameraCardAction<SubstreamSel
await super.execute(api);
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamSelectViewModifier(this._action.camera)],
modifiers: [new SubstreamViewModifier(this._action.camera)],
});
}
}
+6
View File
@@ -2,7 +2,9 @@ import { ActionContext } from 'action';
import { INTERNAL_CALLBACK_ACTION } from '../../config/schema/actions/custom/internal';
import { ActionConfig, AuxillaryActionConfig } from '../../config/schema/actions/types';
import { isAdvancedCameraCardCustomAction } from '../../utils/action';
import { CallEndAction } from './actions/call-end';
import { CallServiceAction } from './actions/call-service';
import { CallStartAction } from './actions/call-start';
import { CameraSelectAction } from './actions/camera-select';
import { CameraUIAction } from './actions/camera-ui';
import { CustomAction } from './actions/custom';
@@ -122,6 +124,10 @@ export class ActionFactory {
return new InfoAction(context, action, options?.config);
case 'menu_toggle':
return new MenuToggleAction(context, action, options?.config);
case 'call_start':
return new CallStartAction(context, action, options?.config);
case 'call_end':
return new CallEndAction(context, action, options?.config);
case 'camera_select':
return new CameraSelectAction(context, action, options?.config);
case 'live_substream_select':
+283
View File
@@ -0,0 +1,283 @@
import { cloneDeep } from 'lodash-es';
import { createNotificationFromText } from '../../components-lib/notification/factory';
import { ConditionStateChange } from '../../conditions/types';
import { localize } from '../../localize/localize';
import { getStreamCameraID } from '../../view/substream';
import { View } from '../../view/view';
import { CardCallAPI } from '../types';
import { SubstreamViewModifier } from '../view/modifiers/substream';
import { CallSession } from './types';
export class CallManager {
private _api: CardCallAPI;
private _call: CallSession | null = null;
constructor(api: CardCallAPI) {
this._api = api;
// A call runs on the live view of a specific camera. Observe the
// condition state so the call can be ended if the view, camera or engaged
// substream moves off what the call started on.
this._api.getConditionStateManager().addListener(this._handleConditionStateChange);
}
// =========================================================================
// Readers
// =========================================================================
public isActive(): boolean {
return !!this._call;
}
public getCall(): CallSession | null {
return this._call;
}
// =========================================================================
// Lifecycle
// =========================================================================
public async start(cameraID?: string, streamID?: string): Promise<void> {
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<boolean> {
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];
}
}
+15
View File
@@ -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;
}
+9 -3
View File
@@ -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;
}
@@ -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<string> = 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<string> = new Set([
// View / camera / substream changes.
...VIEWS_USER_SPECIFIED,
'camera_select',
@@ -31,7 +35,7 @@ const MICROPHONE_SESSION_DISRUPTIVE_ACTIONS: ReadonlySet<string> = 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)
);
}
}
+2 -2
View File
@@ -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 {
@@ -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;
+3 -3
View File
@@ -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)],
}),
});
}
+13 -1
View File
@@ -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 {
@@ -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);
}
}
@@ -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]);
}
}
@@ -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);
}
}
@@ -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<string, string>();
overrides.set(cameraID, this._substreamID);
view.mergeInContext({ live: { overrides } });
}
}
+19 -4
View File
@@ -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<void> =>
): Promise<void> => {
// 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();
+9
View File
@@ -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()}
></advanced-camera-card-menu>
`;
}
@@ -305,6 +312,7 @@ class AdvancedCameraCard extends LitElement {
.getIssueDescriptions(),
})}
.config=${this._config.status_bar}
.autoHideState=${this._getAutoHideState()}
></advanced-camera-card-status-bar>
`;
}
@@ -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
+21
View File
@@ -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]);
@@ -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);
}
+66 -8
View File
@@ -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<HTMLElement, () => 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<void> {
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<void> {
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);
};
+81 -1
View File
@@ -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')) {
+17
View File
@@ -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;
}
@@ -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),
);
+160
View File
@@ -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`<div class="overlay">
<div
class=${classMap({ panel: true, exiting: this._exiting })}
@click=${(ev: Event) => 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'),
},
)}
</div>
</div>`;
}
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`
<ha-icon-button
.label=${label}
title=${label}
?disabled=${!!options?.disabled}
class=${options?.emphasis === 'critical' ? 'critical' : ''}
@click=${() => {
if (options?.handler) {
options.handler();
} else if (options?.action) {
dispatchActionExecutionRequest(this, { actions: options.action });
}
}}
>
<ha-icon icon=${icon}></ha-icon>
</ha-icon-button>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(callControlsStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-call-controls': AdvancedCameraCardCallControls;
}
}
+79 -12
View File
@@ -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`
<div class="embla__slide">
${keyed(
mediaEpoch,
html`<advanced-camera-card-live-provider
.microphoneState=${view?.camera === cameraID
? this.microphoneState
: undefined}
.microphoneStream=${microphoneStream}
.camera=${resolvedCamera}
.targetID=${cameraID}
.label=${cameraMetadata?.title ?? ''}
@@ -270,6 +294,32 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
return view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
}
// Return a microphone stream only for the camera the call runs on, and
// only while that camera's engaged stream is still the call's audio source.
// Keying off the call session (not the selected slide) keeps the microphone
// routed to the call's camera, and stops transmission if the substream has
// since changed.
private _getRelevantMicrophoneStream(
cameraID: string,
view?: View | null,
): MediaStream | null {
const isRelevant =
this.call?.cameraID === cameraID &&
this._getSubstreamCameraID(cameraID, view) ===
(this.call.callCameraID ?? cameraID);
return isRelevant ? this.microphoneState?.stream ?? null : null;
}
private _toggleMute(): void {
const controller = this._mediaLoadedInfoSinkController.get()?.mediaPlayerController;
// Fire-and-forget; the `volumechange` event drives the re-render.
if (controller?.isMuted()) {
controller.unmute();
} else {
controller?.mute();
}
}
private _getCameraNeighbors(): CameraNeighbors | null {
const cameraIDs = this.cameraManager
? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')]
@@ -330,6 +380,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.controlConfig=${this.liveConfig?.controls.next_previous}
.label=${neighbor?.metadata?.title ?? ''}
.icon=${neighbor?.metadata?.icon}
.autoHideState=${resolveAutoHideState(!!this.call)}
?disabled=${!neighbor}
?locked=${!!this.locked}
@click=${(ev) => {
@@ -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`
<advanced-camera-card-carousel
${ref(this._refCarousel)}
@@ -379,6 +435,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
.wheelScrolling=${this.liveConfig?.controls.wheel}
transitionEffect=${this._getTransitionEffect()}
@advanced-camera-card:carousel:select=${this._setViewHandler.bind(this)}
@advanced-camera-card:media:volumechange=${() =>
// 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)}
>
</advanced-camera-card-ptz>
<advanced-camera-card-call-controls
.active=${isCallActive}
.microphoneState=${this.microphoneState}
.muted=${callMediaPlayerController?.isMuted()}
.buttonSize=${this.liveConfig.controls.call.button_size}
@advanced-camera-card:call:mute-toggle=${() => this._toggleMute()}
>
</advanced-camera-card-call-controls>
`;
}
+11 -3
View File
@@ -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}
>
</advanced-camera-card-live-carousel>
`;
+8
View File
@@ -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}
>
+2 -3
View File
@@ -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) =>
@@ -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);
}
}
+9 -3
View File
@@ -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');
+12 -1
View File
@@ -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``;
}
+2 -1
View File
@@ -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);
}
};
+8
View File
@@ -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
+2
View File
@@ -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);
+5
View File
@@ -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)}"
+5 -5
View File
@@ -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 {
+4
View File
@@ -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;
+52
View File
@@ -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] : {},
);
},
];
+4 -6
View File
@@ -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,
@@ -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<typeof callEndActionConfigSchema>;
@@ -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<typeof callStartActionConfigSchema>;
+4
View File
@@ -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<StatusBarActionConfig> =
});
const advancedCameraCardCustomActionSchema = z.union([
callEndActionConfigSchema,
callStartActionConfigSchema,
cameraSelectActionConfigSchema,
effectActionConfigSchema,
generalActionConfigSchema,
+4
View File
@@ -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];
@@ -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),
});
+11 -2
View File
@@ -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];
@@ -0,0 +1,6 @@
import { z } from 'zod';
export const callConditionSchema = z.object({
condition: z.literal('call'),
call: z.boolean().optional(),
});
@@ -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(),
});
+2
View File
@@ -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,
+27 -6
View File
@@ -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'])
+5
View File
@@ -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),
+6
View File
@@ -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
+7
View File
@@ -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),
+12 -1
View File
@@ -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';
+88 -7
View File
@@ -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<string, string> = {
[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,
},
+26 -1
View File
@@ -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",
+56
View File
@@ -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);
}
}
+6
View File
@@ -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;
+26 -3
View File
@@ -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,
+11 -1
View File
@@ -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));
}
+3 -36
View File
@@ -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 {
+46
View File
@@ -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);
}
}
+21
View File
@@ -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
******/
+6 -1
View File
@@ -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;
+8
View File
@@ -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;
+26
View File
@@ -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?: {
+18
View File
@@ -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<AnimationEvent, 'target' | 'currentTarget' | 'animationName'>,
): boolean {
return ev.target === ev.currentTarget && ev.animationName === POP_OUT_ANIMATION_NAME;
}
+1 -1
View File
@@ -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 {
-44
View File
@@ -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<string, string> = 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<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(view.camera);
}
};
+22
View File
@@ -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;
};
@@ -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();
});
@@ -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',
);
});
@@ -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)],
});
});
@@ -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<string | null> => {
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();
});
});
@@ -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)],
});
});
@@ -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],
+735
View File
@@ -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);
});
});
+8
View File
@@ -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],
+37 -13
View File
@@ -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);
@@ -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();
@@ -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();
});
@@ -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,
@@ -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);
});
@@ -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);
});
});
@@ -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');
});
@@ -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();
});
});

Some files were not shown because too many files have changed in this diff Show More