fix: Improve 2-way audio detection (#2293)

- For Frigate cameras, you must be running at least [integration
v5.12.0](https://github.com/blakeblackshear/frigate-hass-integration/releases/tag/v5.12.0).
 - Closes: #2191 

Includes a significant refactor of cameras, and the introduction of a
`2-way-audio` capability that is dynamically fetched from `go2rtc`. One
gotcha is if you previously had a substream with 2-way audio, you may
need to modify

```yaml
 - camera_entity: camera.foo
    capabilities:
      disable_except:
        - substream
```

... to ...

```yaml
 - camera_entity: camera.foo
    capabilities:
      disable_except:
        - substream
        - 2-way-audio
```
This commit is contained in:
Dermot Duffy
2025-12-20 13:06:09 -08:00
committed by GitHub
parent 588ed6c98a
commit 1e821f09c6
47 changed files with 2240 additions and 958 deletions
+36 -46
View File
@@ -15,11 +15,7 @@ import { VideoMediaPlayerController } from '../../../../components-lib/media-pla
import { MicrophoneConfig } from '../../../../config/schema/live.js';
import { homeAssistantSignPath } from '../../../../ha/sign-path.js';
import { HomeAssistant } from '../../../../ha/types.js';
import {
addDynamicProxyURL,
getWebProxiedURL,
shouldUseWebProxy,
} from '../../../../ha/web-proxy.js';
import { createProxiedEndpointIfNecessary } from '../../../../ha/web-proxy.js';
import { localize } from '../../../../localize/localize.js';
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
import { MediaPlayer, MediaPlayerController, Message } from '../../../../types.js';
@@ -100,12 +96,13 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
protected async _getPlayerSource(): Promise<string | null> {
const cameraConfig = this.camera?.getConfig();
const proxyConfig = this.camera?.getProxyConfig();
if (!this.hass || !cameraConfig) {
return null;
}
const endpoint = this.cameraEndpoints?.go2rtc;
if (!endpoint) {
const streamEndpoint = this.cameraEndpoints?.go2rtc;
if (!streamEndpoint) {
this._handleError({
message: localize('error.live_camera_no_endpoint'),
context: cameraConfig,
@@ -113,56 +110,49 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
return null;
}
const proxyConfig = this.camera?.getProxyConfig();
let src: string | null = endpoint.endpoint;
let sign: boolean = endpoint.sign ?? false;
let result: string | null = null;
if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'live')) {
if (proxyConfig.dynamic) {
try {
await addDynamicProxyURL(this.hass, endpoint.endpoint, {
proxyConfig,
ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS,
try {
const endpoint = await createProxiedEndpointIfNecessary(
this.hass,
streamEndpoint,
proxyConfig,
{
context: 'live',
ttl: GO2RTC_URL_SIGN_EXPIRY_SECONDS,
websocket: true,
// The link may need to be opened multiple times.
openLimit: 0,
});
} catch (e) {
this._handleError(
{
message: localize('error.failed_proxy'),
context: cameraConfig,
},
e as Error,
);
return null;
}
}
// The link may need to be opened multiple times.
openLimit: 0,
},
);
src = getWebProxiedURL(endpoint.endpoint, { websocket: true });
sign = true;
}
if (src && sign) {
try {
src = await homeAssistantSignPath(
if (endpoint.sign) {
result = await homeAssistantSignPath(
this.hass,
src,
endpoint.endpoint,
GO2RTC_URL_SIGN_EXPIRY_SECONDS,
);
} catch (e) {
this._handleError(
{
if (!result) {
this._handleError({
message: localize('error.failed_sign'),
context: cameraConfig,
},
e as Error,
);
return null;
});
}
} else {
result = endpoint.endpoint;
}
} catch (e) {
this._handleError(
{
message: localize('error.failed_proxy'),
context: cameraConfig,
},
e as Error,
);
}
return src;
return result;
}
protected async _createPlayer(): Promise<void> {
@@ -1,4 +1,8 @@
import { mayHaveAudio } from '../../../../utils/audio';
import {
addAudioTracksMuteStateListener,
has2WayAudio,
hasAudio,
} from '../../../../utils/audio';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
@@ -172,6 +176,29 @@ export class VideoRTC extends HTMLElement {
* @type {boolean}}
*/
this.controls = true;
/**
* [internal] Cleanup function for audio track mute state listener.
* @type {Function | null}
*/
this._audioTracksMuteStateCleanup = null;
}
/**
* Dispatch a media loaded event with current capabilities.
*/
_dispatchMediaLoadedEvent() {
dispatchMediaLoadedEvent(this, this.video, {
...(this.mediaPlayerController && {
mediaPlayerController: this.mediaPlayerController,
}),
capabilities: {
has2WayAudio: has2WayAudio(this.pc),
hasAudio: hasAudio(this.video, this.pc, this.mseCodecs),
supportsPause: true,
},
technology: getTechnologyForVideoRTC(this),
});
}
/**
@@ -352,21 +379,13 @@ export class VideoRTC extends HTMLElement {
if (this.controls) {
hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
}
dispatchMediaLoadedEvent(this, this.video, {
...(this.mediaPlayerController && {
mediaPlayerController: this.mediaPlayerController,
}),
capabilities: {
// 2-way audio is only supported on WebRTC connections. The state of
// `this.microphoneStream` is not taken into account here since
// that can be created after the fact -- this is purely saying that
// were a microphone stream available it could be used usefully.
supports2WayAudio: !!this.pc,
supportsPause: true,
hasAudio: mayHaveAudio(this.video),
},
technology: getTechnologyForVideoRTC(this),
});
this._dispatchMediaLoadedEvent();
// Listen for audio track mute/unmute changes and re-dispatch
this._audioTracksMuteStateCleanup?.();
this._audioTracksMuteStateCleanup = addAudioTracksMuteStateListener(this.pc, () =>
this._dispatchMediaLoadedEvent(),
);
};
this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
this.video.onplay = () => dispatchMediaPlayEvent(this);
@@ -415,6 +434,9 @@ export class VideoRTC extends HTMLElement {
this.video.src = '';
this.video.srcObject = null;
this._audioTracksMuteStateCleanup?.();
this._audioTracksMuteStateCleanup = null;
}
/**