Files
advanced-camera-card/src/components/menu.ts
T
Dermot Duffy abcba884e5 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
```
2026-06-30 17:45:13 -07:00

198 lines
6.9 KiB
TypeScript

import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
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';
import { getEntityTitle } from '../ha/get-entity-title.js';
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';
@customElement('advanced-camera-card-menu')
export class AdvancedCameraCardMenu extends LitElement {
private _controller = new MenuController(this);
@property({ attribute: false })
public entityRegistryManager?: EntityRegistryManager;
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public lockManagerEpoch?: LockManagerEpoch;
@property({ attribute: false, hasChanged: contentsChanged })
public autoHideState?: AutoHideState;
set menuConfig(menuConfig: MenuConfig) {
this._controller.setMenuConfig(menuConfig);
}
set buttons(buttons: MenuItem[]) {
this._controller.setButtons(buttons);
}
set expanded(expanded: boolean) {
this._controller.setExpanded(expanded);
}
protected willUpdate(changedProps: Map<string, unknown>): void {
if (changedProps.has('lockManagerEpoch')) {
this._controller.setLockManagerEpoch(this.lockManagerEpoch);
}
if (changedProps.has('autoHideState') && this.autoHideState) {
this._controller.setAutoHideState(this.autoHideState);
}
}
public toggleMenu(): void {
this._controller.toggleExpanded();
}
private _renderButton(button: MenuItem): TemplateResult | void {
if (!this.hass) {
return;
}
if (button.type === 'custom:advanced-camera-card-menu-submenu') {
return html` <advanced-camera-card-submenu-button
.hass=${this.hass}
.submenu=${button}
.lockManagerEpoch=${this.lockManagerEpoch}
@action=${(ev) => this._controller.handleAction(ev, button)}
>
</advanced-camera-card-submenu-button>`;
} else if (button.type === 'custom:advanced-camera-card-menu-submenu-select') {
return html` <advanced-camera-card-submenu-select-button
.hass=${this.hass}
.submenuSelect=${button}
.entityRegistryManager=${this.entityRegistryManager}
.lockManagerEpoch=${this.lockManagerEpoch}
@action=${(ev) => this._controller.handleAction(ev, button)}
>
</advanced-camera-card-submenu-select-button>`;
}
const title =
this.hass &&
button.type === 'custom:advanced-camera-card-menu-state-icon' &&
!button.title
? getEntityTitle(this.hass, button.entity)
: button.title;
return html` <ha-icon-button
.actionHandler=${actionHandler({
hasHold: hasAction(button.hold_action),
hasDoubleClick: hasAction(button.double_tap_action),
})}
.label=${title ?? ''}
?disabled=${this._controller.shouldButtonBeInert(button)}
@action=${(ev) => this._controller.handleAction(ev, button)}
>
<advanced-camera-card-icon
?allow-override-non-active-styles=${true}
style="${styleMap(button.style || {})}"
.hass=${this.hass}
.icon=${{
icon: button.icon,
entity: button.entity,
stateColor: button.state_color,
fallback: 'mdi:gesture-tap-button',
}}
></advanced-camera-card-icon>
</ha-icon-button>`;
}
/** Theme-related styling is dynamically injected into the menu depending on
* the configured position, style and alignment to allow precise theming.
* The alternative is a massive (post-sass processing) CSS file would need to
* be shipped to account for every possible combination.
*
* Each rule uses 'var' values that have nested fallbacks of decreasing
* specificity, so the most specific theme variable will match, followed by
* the next most specific, etc.
*/
private _renderPerInstanceStyle(): TemplateResult | void {
const config = this._controller.getMenuConfig();
if (!config) {
return;
}
const position = config.position;
const style = config.style;
const alignment = config.alignment;
const generateValue = (suffix: string): string => {
return `
var(--advanced-camera-card-menu-override-${suffix},
var(--advanced-camera-card-menu-position-${position}-alignment-${alignment}-style-${style}-${suffix},
var(--advanced-camera-card-menu-position-${position}-alignment-${alignment}-${suffix},
var(--advanced-camera-card-menu-position-${position}-${suffix},
var(--advanced-camera-card-menu-style-${style}-${suffix},
var(--advanced-camera-card-menu-alignment-${alignment}-${suffix},
var(--advanced-camera-card-menu-${suffix})))))))`;
};
// By definition `rule` will match the current configuration, the choice is
// actually which of the var(...) variables will be used after the match.
const expandedRule = style === 'hidden' ? '[expanded]' : '';
const rule =
`[data-position='${position}']` +
`[data-style='${style}']` +
`[data-alignment='${alignment}']` +
expandedRule;
return html`<style>
:host(${rule}) {
background: ${generateValue('background')};
ha-icon-button {
color: ${generateValue('button-inactive-color')};
background: ${generateValue('button-background')};
}
}
</style>`;
}
protected render(): TemplateResult | void {
if (!this._controller.shouldRender()) {
return;
}
const matchingButtons = this._controller.getButtons('matching');
const opposingButtons = this._controller.getButtons('opposing');
return html` ${this._renderPerInstanceStyle()}
<div
class="matching"
style="${styleMap({ flex: String(matchingButtons.length) })}"
>
${matchingButtons.map((button) => this._renderButton(button))}
</div>
<div
class="opposing"
style="${styleMap({ flex: String(opposingButtons.length) })}"
>
${opposingButtons.map((button) => this._renderButton(button))}
</div>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(menuStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-menu': AdvancedCameraCardMenu;
}
}