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
+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;