Add experimental 2-way audio support.
This commit is contained in:
@@ -467,6 +467,7 @@ See the [fully expanded live configuration example](#config-expanded-live) for h
|
|||||||
| `actions` | | :white_check_mark: | Actions to use for the `live` view. See [actions](#actions) below.|
|
| `actions` | | :white_check_mark: | Actions to use for the `live` view. See [actions](#actions) below.|
|
||||||
| `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. |
|
| `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. |
|
||||||
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
|
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
|
||||||
|
| `microphone` | | :white_check_mark: | See [microphone](#microphone) below.|
|
||||||
|
|
||||||
#### Live Controls: Thumbnails
|
#### Live Controls: Thumbnails
|
||||||
|
|
||||||
@@ -540,6 +541,23 @@ live:
|
|||||||
| `mode` | `popup-bottom-right` | :white_check_mark: | How to display the live camera title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
|
| `mode` | `popup-bottom-right` | :white_check_mark: | How to display the live camera title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
|
||||||
| `duration_seconds` | `2` | :white_check_mark: | The number of seconds to display the title popup. `0` implies forever.|
|
| `duration_seconds` | `2` | :white_check_mark: | The number of seconds to display the title popup. `0` implies forever.|
|
||||||
|
|
||||||
|
<a name="microphone"></a>
|
||||||
|
|
||||||
|
#### Live: Microphone
|
||||||
|
|
||||||
|
All configuration is under:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
live:
|
||||||
|
microphone:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `disconnect_seconds` | `60` | :white_check_mark: | The number of seconds after which to disconnect the microphone from the stream. `0` implies never. |
|
||||||
|
|
||||||
|
See [Using 2-way audio](#using-2-way-audio) for more information about the very particular requirements that must be followed for 2-way audio to work.
|
||||||
|
|
||||||
### Media Viewer Options
|
### Media Viewer Options
|
||||||
|
|
||||||
The `media_viewer` is used for viewing all `clip`, `snapshot` or recording media, in a media carousel.
|
The `media_viewer` is used for viewing all `clip`, `snapshot` or recording media, in a media carousel.
|
||||||
@@ -1021,6 +1039,44 @@ See [the WebRTC Card live configuration](#webrtc-live-configuration) above, and
|
|||||||
documentation](https://github.com/AlexxIT/WebRTC#configuration) for full
|
documentation](https://github.com/AlexxIT/WebRTC#configuration) for full
|
||||||
configuration options that can be used here.
|
configuration options that can be used here.
|
||||||
|
|
||||||
|
<a name="using-2-way-audio"></a>
|
||||||
|
|
||||||
|
## Using 2-Way Audio
|
||||||
|
|
||||||
|
This card supports 2-way audio (e.g. transmitting audio from a microphone to a suitably equipped camera). Requirements for 2-way audio to work:
|
||||||
|
|
||||||
|
Environmental requirements:
|
||||||
|
* Must have a camera that supports audio out (otherwise what's the point!)
|
||||||
|
* Camera must be supported by `go2rtc` for 2-way audio (see [supported cameras](https://github.com/AlexxIT/go2rtc#two-way-audio)).
|
||||||
|
* Must be accessing your Home Assistant instance over `https`. The browser will enforce this.
|
||||||
|
|
||||||
|
Card requirements:
|
||||||
|
* Only Frigate cameras are supported.
|
||||||
|
* Only the `go2rtc` live provider is supported.
|
||||||
|
* Only the `webrtc` mode supports 2-way audio:
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- camera_entity: camera.front_door
|
||||||
|
live_provider: go2rtc
|
||||||
|
go2rtc:
|
||||||
|
modes:
|
||||||
|
- webrtc
|
||||||
|
```
|
||||||
|
* Must have microphone menu button enabled:
|
||||||
|
```yaml
|
||||||
|
menu:
|
||||||
|
buttons:
|
||||||
|
microphone:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
* The camera will always load without the microphone connected.
|
||||||
|
* To speak, hold-down the microphone menu button.
|
||||||
|
* On first press, this will reset the `webrtc` connection to include 2-way audio.
|
||||||
|
* 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` in the `microphone` configuration have elapsed since the last mute/unmute press.
|
||||||
|
|
||||||
<a name="frigate-card-conditions"></a>
|
<a name="frigate-card-conditions"></a>
|
||||||
|
|
||||||
## Frigate Card Conditions
|
## Frigate Card Conditions
|
||||||
@@ -1178,7 +1234,7 @@ Parameters for the `custom:frigate-card-ptz` element:
|
|||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| - | - |
|
| - | - |
|
||||||
| `action` | Must be `custom:frigate-card-action`. |
|
| `action` | Must be `custom:frigate-card-action`. |
|
||||||
| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_select`, `expand`.|
|
| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`|
|
||||||
|
|
||||||
<a name="custom-actions"></a>
|
<a name="custom-actions"></a>
|
||||||
|
|
||||||
@@ -1196,6 +1252,7 @@ Parameters for the `custom:frigate-card-ptz` element:
|
|||||||
|`media_player`| Perform a media player action. Takes a `media_player` parameter with the entity ID of the media_player on which to perform the action, and a `media_player_action` parameter which should be either `play` or `stop` to play or stop the media in question. |
|
|`media_player`| Perform a media player action. Takes a `media_player` parameter with the entity ID of the media_player on which to perform the action, and a `media_player_action` parameter which should be either `play` or `stop` to play or stop the media in question. |
|
||||||
|`live_substream_select`| Perform a media player action. Takes a `camera` parameter with the [camera ID](#camera-ids) of the substream camera. |
|
|`live_substream_select`| Perform a media player action. Takes a `camera` parameter with the [camera ID](#camera-ids) of the substream camera. |
|
||||||
|`expand`| Expand the card into a dialog/popup. |
|
|`expand`| Expand the card into a dialog/popup. |
|
||||||
|
|`microphone_mute`, `microphone_unmute`| Mute or unmute the microphone. See [Using 2-way audio](#using-2-way-audio). |
|
||||||
|
|
||||||
<a name="views"></a>
|
<a name="views"></a>
|
||||||
|
|
||||||
@@ -1791,6 +1848,8 @@ live:
|
|||||||
position:
|
position:
|
||||||
x: 50
|
x: 50
|
||||||
y: 50
|
y: 50
|
||||||
|
microphone:
|
||||||
|
disconnect_seconds: 60
|
||||||
actions:
|
actions:
|
||||||
entity: light.office_main_lights
|
entity: light.office_main_lights
|
||||||
tap_action:
|
tap_action:
|
||||||
@@ -3650,6 +3709,7 @@ To send an action to a named Frigate card on the dashboard:
|
|||||||
| `live` | :white_check_mark: | |
|
| `live` | :white_check_mark: | |
|
||||||
| `media_player`| :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/frigate-hass-card/issues) if you need this. |
|
| `media_player`| :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/frigate-hass-card/issues) if you need this. |
|
||||||
| `menu_toggle` | :white_check_mark: | |
|
| `menu_toggle` | :white_check_mark: | |
|
||||||
|
| `microphone_mute`, `microphone_unmute`| :heavy_multiplication_x: | |
|
||||||
| `recording` | :white_check_mark: | |
|
| `recording` | :white_check_mark: | |
|
||||||
| `recordings` | :white_check_mark: | |
|
| `recordings` | :white_check_mark: | |
|
||||||
| `snapshot` | :white_check_mark: | |
|
| `snapshot` | :white_check_mark: | |
|
||||||
@@ -3716,6 +3776,18 @@ live:
|
|||||||
mode: none
|
mode: none
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Microphone / 2-way audio doesn't work
|
||||||
|
|
||||||
|
There are many requirements for 2-way audio to work. See [Using 2-way
|
||||||
|
audio](#using-2-way-audio) for more information about these. If your microphone
|
||||||
|
still does not work and you believe you meet all the requirements try
|
||||||
|
eliminating the card from the picture by going directly to the `go2rtc` UI,
|
||||||
|
navigating to `links` for your given stream, then to `webrtc.html` with a
|
||||||
|
microphone. If this does not work correctly with 2-way audio then your issue is
|
||||||
|
with `go2rtc` not with the card. In this case, you could file an issue in [that
|
||||||
|
repo](https://github.com/AlexxIT/go2rtc/issues) with debugging information as
|
||||||
|
appropriate.
|
||||||
|
|
||||||
### Static image URL with credentials doesn't load
|
### Static image URL with credentials doesn't load
|
||||||
|
|
||||||
Your browser will not allow a page/script (like this card) to pass credentials to a cross-origin (different host) image URL for security reasons. There is no way around this unless you could also control the webserver that is serving the image to specifically allow `crossorigin` requests (which is typically not the case for an image served from a camera, for example). The stock Home Assistant Picture Glance card has the same limitation, for the same reasons.
|
Your browser will not allow a page/script (like this card) to pass credentials to a cross-origin (different host) image URL for security reasons. There is no way around this unless you could also control the webserver that is serving the image to specifically allow `crossorigin` requests (which is typically not the case for an image served from a camera, for example). The stock Home Assistant Picture Glance card has the same limitation, for the same reasons.
|
||||||
|
|||||||
@@ -78,14 +78,10 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const start = (): void => {
|
const start = (): void => {
|
||||||
const options = element.actionHandlerOptions;
|
|
||||||
|
|
||||||
if (options?.hasHold) {
|
|
||||||
this.held = false;
|
this.held = false;
|
||||||
this.timer = window.setTimeout(() => {
|
this.timer = window.setTimeout(() => {
|
||||||
this.held = true;
|
this.held = true;
|
||||||
}, this.holdTime);
|
}, this.holdTime);
|
||||||
}
|
|
||||||
|
|
||||||
fireEvent(element, 'action', { action: 'start_tap' });
|
fireEvent(element, 'action', { action: 'start_tap' });
|
||||||
};
|
};
|
||||||
@@ -102,14 +98,13 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
|||||||
// This action handler by default relies on synthetic click events for
|
// This action handler by default relies on synthetic click events for
|
||||||
// touch devices, in order to ensure that embedded cards (e.g. WebRTC)
|
// touch devices, in order to ensure that embedded cards (e.g. WebRTC)
|
||||||
// can use stock click handlers. The exception is for hold events.
|
// can use stock click handlers. The exception is for hold events.
|
||||||
!(options?.hasHold && this.held)
|
!this.held
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options?.hasHold) {
|
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
this.timer = undefined;
|
this.timer = undefined;
|
||||||
}
|
|
||||||
|
|
||||||
fireEvent(element, 'action', { action: 'end_tap' });
|
fireEvent(element, 'action', { action: 'end_tap' });
|
||||||
|
|
||||||
|
|||||||
@@ -989,9 +989,7 @@ export class FrigateCameraManagerEngine
|
|||||||
|
|
||||||
const countSegments = () =>
|
const countSegments = () =>
|
||||||
sum(
|
sum(
|
||||||
cameraIDs.map(
|
cameraIDs.map((cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0),
|
||||||
(cameraID) => this._recordingSegmentsCache.getSize(cameraID) ?? 0,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const segmentsStart = countSegments();
|
const segmentsStart = countSegments();
|
||||||
|
|
||||||
@@ -1081,6 +1079,7 @@ export class FrigateCameraManagerEngine
|
|||||||
supportsSnapshots: !isBirdseye,
|
supportsSnapshots: !isBirdseye,
|
||||||
supportsRecordings: !isBirdseye,
|
supportsRecordings: !isBirdseye,
|
||||||
supportsTimeline: !isBirdseye,
|
supportsTimeline: !isBirdseye,
|
||||||
|
supports2WayAudio: cameraConfig.live_provider === 'go2rtc',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
supportsRecordings: false,
|
supportsRecordings: false,
|
||||||
supportsSnapshots: false,
|
supportsSnapshots: false,
|
||||||
supportsTimeline: false,
|
supportsTimeline: false,
|
||||||
|
supports2WayAudio: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -755,6 +755,7 @@ export class CameraManager {
|
|||||||
supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings),
|
supportsRecordings: perCameraCapabilities.some((cap) => cap?.supportsRecordings),
|
||||||
supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots),
|
supportsSnapshots: perCameraCapabilities.some((cap) => cap?.supportsSnapshots),
|
||||||
supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline),
|
supportsTimeline: perCameraCapabilities.some((cap) => cap?.supportsTimeline),
|
||||||
|
supports2WayAudio: perCameraCapabilities.some((cap) => cap?.supports2WayAudio),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ interface BaseCapabilities {
|
|||||||
supportsRecordings: boolean;
|
supportsRecordings: boolean;
|
||||||
supportsSnapshots: boolean;
|
supportsSnapshots: boolean;
|
||||||
supportsTimeline: boolean;
|
supportsTimeline: boolean;
|
||||||
|
supports2WayAudio: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CameraManagerCapabilities = BaseCapabilities;
|
export type CameraManagerCapabilities = BaseCapabilities;
|
||||||
|
|||||||
+66
-1
@@ -90,6 +90,7 @@ import { ResolvedMediaCache } from './utils/ha/resolved-media.js';
|
|||||||
import { supportsFeature } from './utils/ha/update.js';
|
import { supportsFeature } from './utils/ha/update.js';
|
||||||
import { FrigateCardInitializer } from './utils/initializer.js';
|
import { FrigateCardInitializer } from './utils/initializer.js';
|
||||||
import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
||||||
|
import { MicrophoneController } from './utils/microphone';
|
||||||
import { getActionsFromQueryString } from './utils/querystring.js';
|
import { getActionsFromQueryString } from './utils/querystring.js';
|
||||||
import { View } from './view/view.js';
|
import { View } from './view/view.js';
|
||||||
|
|
||||||
@@ -179,6 +180,9 @@ class FrigateCard extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
protected _expand?: boolean = false;
|
protected _expand?: boolean = false;
|
||||||
|
|
||||||
|
// null implies the user refused microphone access.
|
||||||
|
protected _microphoneController?: MicrophoneController;
|
||||||
|
|
||||||
protected _conditionState?: ConditionState;
|
protected _conditionState?: ConditionState;
|
||||||
|
|
||||||
protected _refMenu: Ref<FrigateCardMenu> = createRef();
|
protected _refMenu: Ref<FrigateCardMenu> = createRef();
|
||||||
@@ -341,7 +345,13 @@ class FrigateCard extends LitElement {
|
|||||||
* Get the style of emphasized menu items.
|
* Get the style of emphasized menu items.
|
||||||
* @returns A StyleInfo.
|
* @returns A StyleInfo.
|
||||||
*/
|
*/
|
||||||
protected _getEmphasizedStyle(): StyleInfo {
|
protected _getEmphasizedStyle(critical?: boolean): StyleInfo {
|
||||||
|
if (critical) {
|
||||||
|
return {
|
||||||
|
animation: 'pulse 3s infinite',
|
||||||
|
color: 'var(--error-color, white)',
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
color: 'var(--primary-color, white)',
|
color: 'var(--primary-color, white)',
|
||||||
};
|
};
|
||||||
@@ -613,6 +623,27 @@ class FrigateCard extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._microphoneController && cameraCapabilities?.supports2WayAudio) {
|
||||||
|
const muted = this._microphoneController.isMuted();
|
||||||
|
buttons.push({
|
||||||
|
icon: this._microphoneController.isForbidden()
|
||||||
|
? 'mdi:microphone-message-off'
|
||||||
|
: muted
|
||||||
|
? 'mdi:microphone-off'
|
||||||
|
: 'mdi:microphone',
|
||||||
|
...this._getConfig().menu.buttons.microphone,
|
||||||
|
type: 'custom:frigate-card-menu-icon',
|
||||||
|
title: localize('config.menu.buttons.microphone'),
|
||||||
|
style: muted ? {} : this._getEmphasizedStyle(true),
|
||||||
|
start_tap_action: createFrigateCardCustomAction(
|
||||||
|
'microphone_unmute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
end_tap_action: createFrigateCardCustomAction(
|
||||||
|
'microphone_mute',
|
||||||
|
) as FrigateCardCustomAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (screenfull.isEnabled && !this._isBeingCasted()) {
|
if (screenfull.isEnabled && !this._isBeingCasted()) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
|
icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
|
||||||
@@ -949,6 +980,21 @@ class FrigateCard extends LitElement {
|
|||||||
if (changedProps.has('_view')) {
|
if (changedProps.has('_view')) {
|
||||||
this._setPropertiesForExpandedMode();
|
this._setPropertiesForExpandedMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldConfig: FrigateCardConfig | undefined =
|
||||||
|
changedProps.get('_overriddenConfig') ?? changedProps.get('_config');
|
||||||
|
const newConfig = this._getConfig();
|
||||||
|
if (
|
||||||
|
(!this._microphoneController ||
|
||||||
|
changedProps.has('_overriddenConfig') ||
|
||||||
|
changedProps.has('_config')) &&
|
||||||
|
oldConfig?.live.microphone.disconnect_seconds !==
|
||||||
|
newConfig.live.microphone.disconnect_seconds
|
||||||
|
) {
|
||||||
|
this._microphoneController = new MicrophoneController(
|
||||||
|
this._getConfig().live.microphone.disconnect_seconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _setPropertiesForMinMaxHeight(): void {
|
protected _setPropertiesForMinMaxHeight(): void {
|
||||||
@@ -1488,6 +1534,24 @@ class FrigateCard extends LitElement {
|
|||||||
case 'diagnostics':
|
case 'diagnostics':
|
||||||
this._diagnostics();
|
this._diagnostics();
|
||||||
break;
|
break;
|
||||||
|
case 'microphone_mute':
|
||||||
|
this._microphoneController?.mute();
|
||||||
|
this.requestUpdate();
|
||||||
|
break;
|
||||||
|
case 'microphone_unmute':
|
||||||
|
const unmuteAndUpdate = () => {
|
||||||
|
this._microphoneController?.unmute();
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!this._microphoneController?.isConnected() &&
|
||||||
|
!this._microphoneController?.isForbidden()
|
||||||
|
) {
|
||||||
|
this._microphoneController?.connect().then(unmuteAndUpdate);
|
||||||
|
} else if (this._microphoneController?.isConnected()) {
|
||||||
|
unmuteAndUpdate();
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Frigate card received unknown card action: ${action}`);
|
console.warn(`Frigate card received unknown card action: ${action}`);
|
||||||
}
|
}
|
||||||
@@ -2020,6 +2084,7 @@ class FrigateCard extends LitElement {
|
|||||||
.nonOverriddenConfig=${this._config}
|
.nonOverriddenConfig=${this._config}
|
||||||
.conditionState=${this._conditionState}
|
.conditionState=${this._conditionState}
|
||||||
.hide=${!!this._message}
|
.hide=${!!this._message}
|
||||||
|
.microphoneStream=${this._microphoneController?.getStream()}
|
||||||
></frigate-card-views>`}
|
></frigate-card-views>`}
|
||||||
${
|
${
|
||||||
// Keep message rendering to last to show messages that may have been
|
// Keep message rendering to last to show messages that may have been
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
CameraConfig,
|
CameraConfig,
|
||||||
ExtendedHomeAssistant,
|
ExtendedHomeAssistant,
|
||||||
FrigateCardMediaPlayer,
|
FrigateCardMediaPlayer,
|
||||||
|
MicrophoneConfig,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import '../image.js';
|
import '../image.js';
|
||||||
import {
|
import {
|
||||||
@@ -34,10 +35,40 @@ const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
|||||||
|
|
||||||
@customElement('frigate-card-live-go2rtc-player')
|
@customElement('frigate-card-live-go2rtc-player')
|
||||||
class FrigateCardGo2RTCPlayer extends VideoRTC {
|
class FrigateCardGo2RTCPlayer extends VideoRTC {
|
||||||
|
protected _microphoneStream?: MediaStream;
|
||||||
|
|
||||||
|
constructor(microphoneStream?: MediaStream) {
|
||||||
|
super();
|
||||||
|
if (microphoneStream) {
|
||||||
|
this._microphoneStream = this._microphoneStream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public play(): void {
|
public play(): void {
|
||||||
// Let Frigate card control auto playing.
|
// Let Frigate card control auto playing.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected reconnect(): void {
|
||||||
|
if (this.wsState !== WebSocket.CLOSED) {
|
||||||
|
// The websocket has onclose handlers, so don't call onconnect directly,
|
||||||
|
// wait until the eventhandlers are finished..
|
||||||
|
this.ws?.addEventListener('close', () => this.onconnect());
|
||||||
|
this.ondisconnect();
|
||||||
|
} else {
|
||||||
|
// Still call ondisconnect() as there may be an RTC connection to
|
||||||
|
// terminate even if the websocket is closed.
|
||||||
|
this.ondisconnect();
|
||||||
|
this.onconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setMicrophoneStream(microphoneStream?: MediaStream): Promise<void> {
|
||||||
|
if (this._microphoneStream !== microphoneStream) {
|
||||||
|
this._microphoneStream = microphoneStream;
|
||||||
|
this.reconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public oninit(): void {
|
public oninit(): void {
|
||||||
super.oninit();
|
super.oninit();
|
||||||
|
|
||||||
@@ -56,6 +87,85 @@ class FrigateCardGo2RTCPlayer extends VideoRTC {
|
|||||||
this.video.muted = true;
|
this.video.muted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is a modified version of onwebrtc() to support 2-way audio.
|
||||||
|
override onwebrtc() {
|
||||||
|
const pc = new RTCPeerConnection(this.pcConfig);
|
||||||
|
|
||||||
|
const video2: HTMLVideoElement = document.createElement('video');
|
||||||
|
video2.addEventListener('loadeddata', (ev) => this.onpcvideo(ev), { once: true });
|
||||||
|
|
||||||
|
pc.addEventListener('icecandidate', (ev) => {
|
||||||
|
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
|
||||||
|
this.send({ type: 'webrtc/candidate', value: candidate });
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.addEventListener('track', (ev) => {
|
||||||
|
// when stream already init
|
||||||
|
if (video2.srcObject !== null) return;
|
||||||
|
|
||||||
|
// when audio track not exist in Chrome
|
||||||
|
if (ev.streams.length === 0) return;
|
||||||
|
|
||||||
|
// when audio track not exist in Firefox
|
||||||
|
if (ev.streams[0].id[0] === '{') return;
|
||||||
|
|
||||||
|
// Filter out tracks that are not video related.
|
||||||
|
if (ev.track.kind !== 'video') return;
|
||||||
|
|
||||||
|
video2.srcObject = ev.streams[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
|
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||||
|
pc.close(); // stop next events
|
||||||
|
|
||||||
|
this.pcState = WebSocket.CLOSED;
|
||||||
|
this.pc = null;
|
||||||
|
|
||||||
|
this.onconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onmessage['webrtc'] = (msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'webrtc/candidate':
|
||||||
|
pc.addIceCandidate({
|
||||||
|
candidate: msg.value,
|
||||||
|
sdpMid: '0',
|
||||||
|
}).catch(() => console.debug);
|
||||||
|
break;
|
||||||
|
case 'webrtc/answer':
|
||||||
|
pc.setRemoteDescription({
|
||||||
|
type: 'answer',
|
||||||
|
sdp: msg.value,
|
||||||
|
}).catch(() => console.debug);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
if (msg.value.indexOf('webrtc/offer') < 0) return;
|
||||||
|
pc.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safari doesn't support "offerToReceiveVideo"
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
|
||||||
|
// Must add microphone tracks prior to making the offer.
|
||||||
|
this._microphoneStream?.getTracks().forEach((track) => {
|
||||||
|
console.info('Adding microphone track', track);
|
||||||
|
pc.addTransceiver(track, { direction: 'sendonly' });
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.createOffer().then((offer) => {
|
||||||
|
pc.setLocalDescription(offer).then(() => {
|
||||||
|
this.send({ type: 'webrtc/offer', value: offer.sdp });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pcState = WebSocket.CONNECTING;
|
||||||
|
this.pc = pc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement('frigate-card-live-go2rtc')
|
@customElement('frigate-card-live-go2rtc')
|
||||||
@@ -69,6 +179,12 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraEndpoints?: CameraEndpoints;
|
public cameraEndpoints?: CameraEndpoints;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneConfig?: MicrophoneConfig;
|
||||||
|
|
||||||
protected _player?: FrigateCardGo2RTCPlayer;
|
protected _player?: FrigateCardGo2RTCPlayer;
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
@@ -141,7 +257,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._player = new FrigateCardGo2RTCPlayer();
|
this._player = new FrigateCardGo2RTCPlayer(this.microphoneStream);
|
||||||
this._player.src = address;
|
this._player.src = address;
|
||||||
this._player.visibilityCheck = false;
|
this._player.visibilityCheck = false;
|
||||||
|
|
||||||
@@ -156,6 +272,9 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
|||||||
if (!this._player || changedProps.has('cameraEndpoints')) {
|
if (!this._player || changedProps.has('cameraEndpoints')) {
|
||||||
this._createPlayer();
|
this._createPlayer();
|
||||||
}
|
}
|
||||||
|
if (changedProps.has('microphoneStream')) {
|
||||||
|
this._player?.setMicrophoneStream(this.microphoneStream);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
|
|||||||
@@ -138,6 +138,9 @@ export class FrigateCardLive extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
// Whether or not the live view is currently in the background (i.e. preloaded
|
// Whether or not the live view is currently in the background (i.e. preloaded
|
||||||
// but not visible)
|
// but not visible)
|
||||||
@state()
|
@state()
|
||||||
@@ -251,6 +254,7 @@ export class FrigateCardLive extends LitElement {
|
|||||||
.liveOverrides=${this.liveOverrides}
|
.liveOverrides=${this.liveOverrides}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
|
.microphoneStream=${this.microphoneStream}
|
||||||
@frigate-card:message=${(ev: CustomEvent<Message>) => {
|
@frigate-card:message=${(ev: CustomEvent<Message>) => {
|
||||||
this._renderKey++;
|
this._renderKey++;
|
||||||
this._messageReceivedPostRender = true;
|
this._messageReceivedPostRender = true;
|
||||||
@@ -315,6 +319,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraManager?: CameraManager;
|
public cameraManager?: CameraManager;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
// Index between camera name and slide number.
|
// Index between camera name and slide number.
|
||||||
protected _cameraToSlide: Record<string, number> = {};
|
protected _cameraToSlide: Record<string, number> = {};
|
||||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||||
@@ -541,6 +548,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
<div class="embla__slide">
|
<div class="embla__slide">
|
||||||
<frigate-card-live-provider
|
<frigate-card-live-provider
|
||||||
?disabled=${this.liveConfig.lazy_load}
|
?disabled=${this.liveConfig.lazy_load}
|
||||||
|
.microphoneStream=${this.view?.camera === cameraID ? this.microphoneStream : undefined}
|
||||||
.cameraConfig=${cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
.cameraEndpoints=${guard(
|
.cameraEndpoints=${guard(
|
||||||
[this.cameraManager, cameraID],
|
[this.cameraManager, cameraID],
|
||||||
@@ -722,6 +730,9 @@ export class FrigateCardLiveProvider
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
protected _isVideoMediaLoaded = false;
|
protected _isVideoMediaLoaded = false;
|
||||||
|
|
||||||
@@ -933,6 +944,8 @@ export class FrigateCardLiveProvider
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.cameraConfig=${this.cameraConfig}
|
.cameraConfig=${this.cameraConfig}
|
||||||
.cameraEndpoints=${this.cameraEndpoints}
|
.cameraEndpoints=${this.cameraEndpoints}
|
||||||
|
.microphoneStream=${this.microphoneStream}
|
||||||
|
.microphoneConfig=${this.liveConfig.microphone}
|
||||||
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
|
||||||
>
|
>
|
||||||
</frigate-card-live-webrtc-card>`
|
</frigate-card-live-webrtc-card>`
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export class FrigateCardViews extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hide?: boolean;
|
public hide?: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public microphoneStream?: MediaStream;
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('view') || changedProps.has('config')) {
|
if (changedProps.has('view') || changedProps.has('config')) {
|
||||||
if (this.view?.is('live') || this._shouldLivePreload()) {
|
if (this.view?.is('live') || this._shouldLivePreload()) {
|
||||||
@@ -204,6 +207,7 @@ export class FrigateCardViews extends LitElement {
|
|||||||
.liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
|
.liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
.microphoneStream=${this.microphoneStream}
|
||||||
class="${classMap(liveClasses)}"
|
class="${classMap(liveClasses)}"
|
||||||
>
|
>
|
||||||
</frigate-card-live>
|
</frigate-card-live>
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const;
|
|||||||
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
|
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
|
||||||
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
|
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
|
||||||
`${CONF_LIVE}.show_image_during_load` as const;
|
`${CONF_LIVE}.show_image_during_load` as const;
|
||||||
|
export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
|
||||||
|
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
|
||||||
|
|
||||||
const CONF_IMAGE = 'image' as const;
|
const CONF_IMAGE = 'image' as const;
|
||||||
export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const;
|
export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const;
|
||||||
|
|||||||
+18
-3
@@ -147,6 +147,7 @@ import {
|
|||||||
CONF_TIMELINE_STYLE,
|
CONF_TIMELINE_STYLE,
|
||||||
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
|
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
|
||||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
|
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
|
||||||
|
CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
|
||||||
} from './const.js';
|
} from './const.js';
|
||||||
import { localize } from './localize/localize.js';
|
import { localize } from './localize/localize.js';
|
||||||
import frigate_card_editor_style from './scss/editor.scss';
|
import frigate_card_editor_style from './scss/editor.scss';
|
||||||
@@ -192,6 +193,7 @@ const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails';
|
|||||||
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
|
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
|
||||||
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
|
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
|
||||||
const MENU_LIVE_LAYOUT = 'live.layout';
|
const MENU_LIVE_LAYOUT = 'live.layout';
|
||||||
|
const MENU_LIVE_MICROPHONE = 'live.microphone';
|
||||||
const MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails';
|
const MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails';
|
||||||
const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter';
|
const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter';
|
||||||
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
|
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
|
||||||
@@ -994,10 +996,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
configPathShowRecordings: string,
|
configPathShowRecordings: string,
|
||||||
defaultShowRecordings: boolean,
|
defaultShowRecordings: boolean,
|
||||||
): TemplateResult {
|
): TemplateResult {
|
||||||
return html`
|
return html` ${this._renderOptionSelector(
|
||||||
${this._renderOptionSelector(configPathStyle, this._timelineStyleTypes, {
|
configPathStyle,
|
||||||
|
this._timelineStyleTypes,
|
||||||
|
{
|
||||||
label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
|
label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
${this._renderNumberInput(configPathWindowSeconds, {
|
${this._renderNumberInput(configPathWindowSeconds, {
|
||||||
label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`),
|
label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`),
|
||||||
})}
|
})}
|
||||||
@@ -1755,6 +1760,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
${this._renderMenuButton('expand') /* */}
|
${this._renderMenuButton('expand') /* */}
|
||||||
${this._renderMenuButton('timeline')}
|
${this._renderMenuButton('timeline')}
|
||||||
${this._renderMenuButton('media_player')}
|
${this._renderMenuButton('media_player')}
|
||||||
|
${this._renderMenuButton('microphone')}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -1844,6 +1850,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
CONF_LIVE_LAYOUT_POSITION_X,
|
CONF_LIVE_LAYOUT_POSITION_X,
|
||||||
CONF_LIVE_LAYOUT_POSITION_Y,
|
CONF_LIVE_LAYOUT_POSITION_Y,
|
||||||
)}
|
)}
|
||||||
|
${this._putInSubmenu(
|
||||||
|
MENU_LIVE_MICROPHONE,
|
||||||
|
true,
|
||||||
|
'config.live.microphone.editor_label',
|
||||||
|
{ name: 'mdi:microphone' },
|
||||||
|
html`
|
||||||
|
${this._renderNumberInput(CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS)}
|
||||||
|
`,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
Vendored
+9
-1
@@ -12,11 +12,19 @@ export class VideoRTC extends HTMLElement {
|
|||||||
video: HTMLVideoElement;
|
video: HTMLVideoElement;
|
||||||
ws: WebSocket | null;
|
ws: WebSocket | null;
|
||||||
wsURL: string;
|
wsURL: string;
|
||||||
pc: RTCPeerConnection;
|
pc: RTCPeerConnection | null;
|
||||||
connectTS: number;
|
connectTS: number;
|
||||||
mseCodecs: string;
|
mseCodecs: string;
|
||||||
|
|
||||||
src: string | URL;
|
src: string | URL;
|
||||||
|
|
||||||
oninit(): void;
|
oninit(): void;
|
||||||
|
send(value: unknown): void;
|
||||||
|
onpcvideo(ev: Event): void;
|
||||||
|
onconnect(): void;
|
||||||
|
ondisconnect(): void;
|
||||||
|
onclose(): void;
|
||||||
|
onopen(): void;
|
||||||
|
onwebrtc(): void;
|
||||||
|
onmessage: Record<string, (msg: { type: string; value: string }) => void>;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
-1
@@ -27,7 +27,8 @@ export class VideoRTC extends HTMLElement {
|
|||||||
"hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra)
|
"hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra)
|
||||||
"mp4a.40.2", // AAC LC
|
"mp4a.40.2", // AAC LC
|
||||||
"mp4a.40.5", // AAC HE
|
"mp4a.40.5", // AAC HE
|
||||||
"opus", // OPUS Chrome
|
"flac", // FLAC (PCM compatible)
|
||||||
|
"opus", // OPUS Chrome, Firefox
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -218,6 +218,11 @@
|
|||||||
"layout": "Live Layout",
|
"layout": "Live Layout",
|
||||||
"lazy_load": "Live cameras are lazily loaded",
|
"lazy_load": "Live cameras are lazily loaded",
|
||||||
"lazy_unload": "Live cameras are lazily unloaded",
|
"lazy_unload": "Live cameras are lazily unloaded",
|
||||||
|
"microphone": {
|
||||||
|
"disconnect_seconds": "Seconds after which to disconnect microphone (0=never)",
|
||||||
|
"editor_label": "Microphone",
|
||||||
|
"enabled": "Microphone enabled"
|
||||||
|
},
|
||||||
"preload": "Preload live view in the background",
|
"preload": "Preload live view in the background",
|
||||||
"show_image_during_load": "Show still image while the live stream is loading",
|
"show_image_during_load": "Show still image while the live stream is loading",
|
||||||
"transition_effect": "Live camera transition effect"
|
"transition_effect": "Live camera transition effect"
|
||||||
@@ -267,6 +272,7 @@
|
|||||||
"image": "Image",
|
"image": "Image",
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"media_player": "Send to media player",
|
"media_player": "Send to media player",
|
||||||
|
"microphone": "Microphone",
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"recordings": "Recordings",
|
"recordings": "Recordings",
|
||||||
"snapshots": "Snapshots",
|
"snapshots": "Snapshots",
|
||||||
|
|||||||
@@ -218,6 +218,11 @@
|
|||||||
"layout": "Disposizione dal vivo",
|
"layout": "Disposizione dal vivo",
|
||||||
"lazy_load": "Le telecamere dal vivo sono pigramente cariche",
|
"lazy_load": "Le telecamere dal vivo sono pigramente cariche",
|
||||||
"lazy_unload": "Le telecamere dal vivo sono pigramente non caricate",
|
"lazy_unload": "Le telecamere dal vivo sono pigramente non caricate",
|
||||||
|
"microphone": {
|
||||||
|
"disconnect_seconds": "",
|
||||||
|
"editor_label": "",
|
||||||
|
"enabled": ""
|
||||||
|
},
|
||||||
"preload": "Precarica Live View in background",
|
"preload": "Precarica Live View in background",
|
||||||
"show_image_during_load": "Mostra un'immagine fissa durante il caricamento del live streaming",
|
"show_image_during_load": "Mostra un'immagine fissa durante il caricamento del live streaming",
|
||||||
"transition_effect": "Effetto di transizione della telecamera dal vivo"
|
"transition_effect": "Effetto di transizione della telecamera dal vivo"
|
||||||
|
|||||||
@@ -218,6 +218,11 @@
|
|||||||
"layout": "Layout dinâmico",
|
"layout": "Layout dinâmico",
|
||||||
"lazy_load": "As câmeras ao vivo são carregadas lentamente",
|
"lazy_load": "As câmeras ao vivo são carregadas lentamente",
|
||||||
"lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
|
"lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
|
||||||
|
"microphone": {
|
||||||
|
"disconnect_seconds": "",
|
||||||
|
"editor_label": "",
|
||||||
|
"enabled": ""
|
||||||
|
},
|
||||||
"preload": "Pré-carregar a visualização ao vivo em segundo plano",
|
"preload": "Pré-carregar a visualização ao vivo em segundo plano",
|
||||||
"show_image_during_load": "Mostrar imagem estática enquanto a transmissão ao vivo está carregando",
|
"show_image_during_load": "Mostrar imagem estática enquanto a transmissão ao vivo está carregando",
|
||||||
"transition_effect": "Efeito de transição de câmera ao vivo"
|
"transition_effect": "Efeito de transição de câmera ao vivo"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ ha-icon-button.button {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
|
|||||||
'image',
|
'image',
|
||||||
'live',
|
'live',
|
||||||
'menu_toggle',
|
'menu_toggle',
|
||||||
|
'microphone_mute',
|
||||||
|
'microphone_unmute',
|
||||||
'recording',
|
'recording',
|
||||||
'recordings',
|
'recordings',
|
||||||
'snapshot',
|
'snapshot',
|
||||||
@@ -409,6 +411,20 @@ const imageBaseConfigSchema = z.object({
|
|||||||
* Live provider options
|
* Live provider options
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const microphoneConfigDefault = {
|
||||||
|
disconnect_seconds: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
const microphoneConfigSchema = z
|
||||||
|
.object({
|
||||||
|
disconnect_seconds: z
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.default(microphoneConfigDefault.disconnect_seconds),
|
||||||
|
})
|
||||||
|
.default(microphoneConfigDefault);
|
||||||
|
export type MicrophoneConfig = z.infer<typeof microphoneConfigSchema>;
|
||||||
|
|
||||||
const go2rtcConfigSchema = z.object({
|
const go2rtcConfigSchema = z.object({
|
||||||
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
|
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
|
||||||
stream: z.string().optional(),
|
stream: z.string().optional(),
|
||||||
@@ -932,6 +948,9 @@ const liveConfigDefault = {
|
|||||||
duration_seconds: 2,
|
duration_seconds: 2,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
microphone: {
|
||||||
|
...microphoneConfigDefault,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
|
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
|
||||||
@@ -975,6 +994,7 @@ const liveOverridableConfigSchema = z
|
|||||||
.boolean()
|
.boolean()
|
||||||
.default(liveConfigDefault.show_image_during_load),
|
.default(liveConfigDefault.show_image_during_load),
|
||||||
layout: mediaLayoutConfigSchema.optional(),
|
layout: mediaLayoutConfigSchema.optional(),
|
||||||
|
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
|
||||||
})
|
})
|
||||||
.merge(actionsSchema);
|
.merge(actionsSchema);
|
||||||
|
|
||||||
@@ -1037,6 +1057,7 @@ const menuConfigDefault = {
|
|||||||
fullscreen: visibleButtonDefault,
|
fullscreen: visibleButtonDefault,
|
||||||
expand: hiddenButtonDefault,
|
expand: hiddenButtonDefault,
|
||||||
media_player: visibleButtonDefault,
|
media_player: visibleButtonDefault,
|
||||||
|
microphone: hiddenButtonDefault,
|
||||||
recordings: hiddenButtonDefault,
|
recordings: hiddenButtonDefault,
|
||||||
},
|
},
|
||||||
button_size: 40,
|
button_size: 40,
|
||||||
@@ -1073,6 +1094,7 @@ const menuConfigSchema = z
|
|||||||
media_player: visibleButtonSchema.default(
|
media_player: visibleButtonSchema.default(
|
||||||
menuConfigDefault.buttons.media_player,
|
menuConfigDefault.buttons.media_player,
|
||||||
),
|
),
|
||||||
|
microphone: hiddenButtonSchema.default(menuConfigDefault.buttons.microphone),
|
||||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||||
})
|
})
|
||||||
.default(menuConfigDefault.buttons),
|
.default(menuConfigDefault.buttons),
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export class MicrophoneController {
|
||||||
|
protected _stream?: MediaStream | null;
|
||||||
|
protected _timerID: number | null = null;
|
||||||
|
|
||||||
|
// We keep mute state separate from the stream state so that mute/unmute can
|
||||||
|
// be expressed before the stream is created -- and when it's create it will
|
||||||
|
// have the right mute status.
|
||||||
|
protected _mute = true;
|
||||||
|
|
||||||
|
protected _disconnectSeconds: number;
|
||||||
|
|
||||||
|
constructor(disconnectSeconds?: number) {
|
||||||
|
this._disconnectSeconds = disconnectSeconds ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this._stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: true,
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this._stream = null;
|
||||||
|
}
|
||||||
|
this._setMute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
this._stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
this._stream = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStream(): MediaStream | undefined {
|
||||||
|
return this._stream ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _setMute(): void {
|
||||||
|
this._stream?.getTracks().forEach((track) => {
|
||||||
|
track.enabled = !this._mute;
|
||||||
|
});
|
||||||
|
this._startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public mute(): void {
|
||||||
|
this._mute = true;
|
||||||
|
this._setMute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public unmute(): void {
|
||||||
|
this._mute = false;
|
||||||
|
this._setMute();
|
||||||
|
}
|
||||||
|
|
||||||
|
public isConnected(): boolean {
|
||||||
|
return !!this._stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isForbidden(): boolean {
|
||||||
|
return this._stream === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isMuted(): boolean {
|
||||||
|
// For safety, this function always returns the stream mute status directly
|
||||||
|
// (rather the internal state).
|
||||||
|
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _clearTimer(): void {
|
||||||
|
if (this._timerID) {
|
||||||
|
window.clearTimeout(this._timerID);
|
||||||
|
this._timerID = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _startTimer(): void {
|
||||||
|
if (this._disconnectSeconds) {
|
||||||
|
this._clearTimer();
|
||||||
|
this._timerID = window.setTimeout(() => {
|
||||||
|
this._clearTimer();
|
||||||
|
this.disconnect();
|
||||||
|
}, this._disconnectSeconds * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||||
|
import { MicrophoneController } from '../../src/utils/microphone';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
const navigatorMock = {
|
||||||
|
mediaDevices: {
|
||||||
|
getUserMedia: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('MicrophoneController', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('navigator', navigatorMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockStream = (mute?: boolean): MediaStream => {
|
||||||
|
const stream = mock<MediaStream>();
|
||||||
|
const track = mock<MediaStreamTrack>();
|
||||||
|
track.enabled = !mute;
|
||||||
|
stream.getTracks.mockImplementation(() => [track]);
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should be muted on creation', () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
expect(controller).toBeTruthy();
|
||||||
|
expect(controller.isMuted()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be undefined without creation', () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
expect(controller.getStream()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should connect', async () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
const stream = createMockStream();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(stream);
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isConnected()).toBeTruthy();
|
||||||
|
expect(controller.getStream()).toBe(stream);
|
||||||
|
expect(controller.isMuted()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be forbidden when permission denied', async () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockRejectedValue(new Error());
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isConnected()).toBeFalsy();
|
||||||
|
expect(controller.isForbidden()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mute', async () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
|
||||||
|
await controller.connect();
|
||||||
|
controller.mute();
|
||||||
|
expect(controller.isMuted()).toBeTruthy();
|
||||||
|
|
||||||
|
controller.unmute();
|
||||||
|
expect(controller.isMuted()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be unmuted on creation if unmute called first', async () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
controller.unmute();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isMuted()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disconnect', async () => {
|
||||||
|
const controller = new MicrophoneController();
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isConnected()).toBeTruthy();
|
||||||
|
|
||||||
|
await controller.disconnect();
|
||||||
|
expect(controller.isConnected()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should automatically disconnect', async () => {
|
||||||
|
const seconds = 10;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const controller = new MicrophoneController(seconds);
|
||||||
|
navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
|
||||||
|
|
||||||
|
await controller.connect();
|
||||||
|
expect(controller.isConnected()).toBeTruthy();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(seconds * 1000);
|
||||||
|
|
||||||
|
expect(controller.isConnected()).toBeFalsy();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user