Merge pull request #1149 from dermotduffy/mic-connect-on-load
Add option to have microphone `always_connected`
This commit is contained in:
@@ -557,7 +557,8 @@ live:
|
|||||||
|
|
||||||
| Option | Default | Overridable | Description |
|
| 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. |
|
| `always_connected` | `false` | :white_check_mark: | 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.|
|
||||||
|
| `disconnect_seconds` | `60` | :white_check_mark: | The number of seconds after microphone usage to disconnect the microphone from the stream. `0` implies never. Not relevant if `always_connected` is `true`.|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -1098,7 +1099,7 @@ menu:
|
|||||||
Usage:
|
Usage:
|
||||||
* The camera will always load without the microphone connected.
|
* The camera will always load without the microphone connected.
|
||||||
* To speak, hold-down the microphone menu button.
|
* To speak, hold-down the microphone menu button.
|
||||||
* On first press, this will reset the `webrtc` connection to include 2-way audio.
|
* On first press, this will reset the `webrtc` connection to include 2-way audio (unless the `always_connected` microphone option is set to `true`).
|
||||||
* Thereafter hold the microphone button down to unmute/speak, let go to mute.
|
* 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.
|
* 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.
|
||||||
|
|
||||||
@@ -1877,6 +1878,7 @@ live:
|
|||||||
x: 50
|
x: 50
|
||||||
y: 50
|
y: 50
|
||||||
microphone:
|
microphone:
|
||||||
|
always_connected: false
|
||||||
disconnect_seconds: 60
|
disconnect_seconds: 60
|
||||||
actions:
|
actions:
|
||||||
entity: light.office_main_lights
|
entity: light.office_main_lights
|
||||||
|
|||||||
+30
-7
@@ -152,6 +152,7 @@ enum InitializationAspect {
|
|||||||
SIDE_LOAD_ELEMENTS = 'side-load-elements',
|
SIDE_LOAD_ELEMENTS = 'side-load-elements',
|
||||||
MEDIA_PLAYERS = 'media-players',
|
MEDIA_PLAYERS = 'media-players',
|
||||||
CAMERAS = 'cameras',
|
CAMERAS = 'cameras',
|
||||||
|
MICROPHONE = 'microphone',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1009,8 +1010,6 @@ class FrigateCard extends LitElement {
|
|||||||
setPerformanceCSSStyles(this, this._cardWideConfig?.performance);
|
setPerformanceCSSStyles(this, this._cardWideConfig?.performance);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._initializeBackground();
|
|
||||||
|
|
||||||
if (changedProps.has('_view')) {
|
if (changedProps.has('_view')) {
|
||||||
this._setPropertiesForExpandedMode();
|
this._setPropertiesForExpandedMode();
|
||||||
}
|
}
|
||||||
@@ -1025,10 +1024,16 @@ class FrigateCard extends LitElement {
|
|||||||
oldConfig?.live.microphone.disconnect_seconds !==
|
oldConfig?.live.microphone.disconnect_seconds !==
|
||||||
newConfig.live.microphone.disconnect_seconds
|
newConfig.live.microphone.disconnect_seconds
|
||||||
) {
|
) {
|
||||||
|
const config = this._getConfig();
|
||||||
this._microphoneController = new MicrophoneController(
|
this._microphoneController = new MicrophoneController(
|
||||||
this._getConfig().live.microphone.disconnect_seconds,
|
config.live.microphone.always_connected
|
||||||
|
? undefined
|
||||||
|
: config.live.microphone.disconnect_seconds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Must be called after the microphoneController is created.
|
||||||
|
this._initializeBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _setPropertiesForMinMaxHeight(): void {
|
protected _setPropertiesForMinMaxHeight(): void {
|
||||||
@@ -1204,6 +1209,10 @@ class FrigateCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async _initializeMicrophone(): Promise<void> {
|
||||||
|
await this._microphoneController?.connect();
|
||||||
|
}
|
||||||
|
|
||||||
protected async _initializeMediaPlayers(hass: HomeAssistant): Promise<void> {
|
protected async _initializeMediaPlayers(hass: HomeAssistant): Promise<void> {
|
||||||
const isValidMediaPlayer = (entityID: string): boolean => {
|
const isValidMediaPlayer = (entityID: string): boolean => {
|
||||||
if (entityID.startsWith('media_player.')) {
|
if (entityID.startsWith('media_player.')) {
|
||||||
@@ -1299,19 +1308,33 @@ class FrigateCard extends LitElement {
|
|||||||
protected _initializeBackground(): void {
|
protected _initializeBackground(): void {
|
||||||
const hass = this._hass;
|
const hass = this._hass;
|
||||||
const config = this._getConfig();
|
const config = this._getConfig();
|
||||||
const needMediaPlayers = config.menu.buttons.media_player.enabled;
|
if (!hass || !config) {
|
||||||
if (!hass || !config || !needMediaPlayers) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._initializer.isInitialized(InitializationAspect.MEDIA_PLAYERS)) {
|
if (
|
||||||
|
this._initializer.isInitializedMultiple([
|
||||||
|
...(config.menu.buttons.media_player.enabled
|
||||||
|
? [InitializationAspect.MEDIA_PLAYERS]
|
||||||
|
: []),
|
||||||
|
...(config.live.microphone.always_connected
|
||||||
|
? [InitializationAspect.MICROPHONE]
|
||||||
|
: []),
|
||||||
|
])
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._initializer
|
this._initializer
|
||||||
.initializeMultipleIfNecessary({
|
.initializeMultipleIfNecessary({
|
||||||
|
...(config.menu.buttons.media_player.enabled && {
|
||||||
[InitializationAspect.MEDIA_PLAYERS]: async () =>
|
[InitializationAspect.MEDIA_PLAYERS]: async () =>
|
||||||
await this._initializeMediaPlayers(hass),
|
await this._initializeMediaPlayers(hass),
|
||||||
|
}),
|
||||||
|
...(config.live.microphone.always_connected && {
|
||||||
|
[InitializationAspect.MICROPHONE]: async () =>
|
||||||
|
await this._initializeMicrophone(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.then((initialized) => {
|
.then((initialized) => {
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
@@ -1601,7 +1624,7 @@ class FrigateCard extends LitElement {
|
|||||||
|
|
||||||
// Must requestUpdate to show the correct microphone state in the
|
// Must requestUpdate to show the correct microphone state in the
|
||||||
// menu.
|
// menu.
|
||||||
this._microphoneController?.connect().then(() => this.requestUpdate());
|
this._initializeMicrophone().then(() => this.requestUpdate());
|
||||||
} else if (this._microphoneController?.isConnected()) {
|
} else if (this._microphoneController?.isConnected()) {
|
||||||
this._microphoneController.unmute();
|
this._microphoneController.unmute();
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class FrigateCardGo2RTCPlayer extends VideoRTC {
|
|||||||
constructor(microphoneStream?: MediaStream) {
|
constructor(microphoneStream?: MediaStream) {
|
||||||
super();
|
super();
|
||||||
if (microphoneStream) {
|
if (microphoneStream) {
|
||||||
this._microphoneStream = this._microphoneStream;
|
this._microphoneStream = microphoneStream;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ 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 =
|
export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
|
||||||
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
|
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
|
||||||
|
export const CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED =
|
||||||
|
`${CONF_LIVE}.microphone.always_connected` as const;
|
||||||
export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const;
|
export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const;
|
||||||
|
|
||||||
const CONF_IMAGE = 'image' as const;
|
const CONF_IMAGE = 'image' as const;
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ import {
|
|||||||
CONF_VIEW_UPDATE_FORCE,
|
CONF_VIEW_UPDATE_FORCE,
|
||||||
CONF_VIEW_UPDATE_SECONDS,
|
CONF_VIEW_UPDATE_SECONDS,
|
||||||
MEDIA_CHUNK_SIZE_MAX,
|
MEDIA_CHUNK_SIZE_MAX,
|
||||||
|
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
||||||
} from './const.js';
|
} from './const.js';
|
||||||
import { localize } from './localize/localize.js';
|
import { localize } from './localize/localize.js';
|
||||||
import { setLowPerformanceProfile } from './performance.js';
|
import { setLowPerformanceProfile } from './performance.js';
|
||||||
@@ -1878,6 +1879,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
{ name: 'mdi:microphone' },
|
{ name: 'mdi:microphone' },
|
||||||
html`
|
html`
|
||||||
${this._renderNumberInput(CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS)}
|
${this._renderNumberInput(CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS)}
|
||||||
|
${this._renderSwitch(
|
||||||
|
CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
|
||||||
|
this._defaults.live.microphone.always_connected,
|
||||||
|
)}
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -220,6 +220,7 @@
|
|||||||
"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": {
|
"microphone": {
|
||||||
|
"always_connected": "Always keep the microphone connected",
|
||||||
"disconnect_seconds": "Seconds after which to disconnect microphone (0=never)",
|
"disconnect_seconds": "Seconds after which to disconnect microphone (0=never)",
|
||||||
"editor_label": "Microphone",
|
"editor_label": "Microphone",
|
||||||
"enabled": "Microphone enabled"
|
"enabled": "Microphone enabled"
|
||||||
|
|||||||
@@ -220,6 +220,7 @@
|
|||||||
"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": {
|
"microphone": {
|
||||||
|
"always_connected": "",
|
||||||
"disconnect_seconds": "",
|
"disconnect_seconds": "",
|
||||||
"editor_label": "",
|
"editor_label": "",
|
||||||
"enabled": ""
|
"enabled": ""
|
||||||
|
|||||||
@@ -220,6 +220,7 @@
|
|||||||
"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": {
|
"microphone": {
|
||||||
|
"always_connected": "",
|
||||||
"disconnect_seconds": "",
|
"disconnect_seconds": "",
|
||||||
"editor_label": "",
|
"editor_label": "",
|
||||||
"enabled": ""
|
"enabled": ""
|
||||||
|
|||||||
@@ -212,6 +212,12 @@
|
|||||||
"layout": "layout",
|
"layout": "layout",
|
||||||
"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": {
|
||||||
|
"always_connected": "",
|
||||||
|
"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": "Mostar imagem durante o carregamento",
|
"show_image_during_load": "Mostar imagem durante o carregamento",
|
||||||
"transition_effect": "Efeito de transição de câmera ao vivo",
|
"transition_effect": "Efeito de transição de câmera ao vivo",
|
||||||
|
|||||||
@@ -414,11 +414,13 @@ const imageBaseConfigSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const microphoneConfigDefault = {
|
const microphoneConfigDefault = {
|
||||||
|
always_connected: false,
|
||||||
disconnect_seconds: 60,
|
disconnect_seconds: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
const microphoneConfigSchema = z
|
const microphoneConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
always_connected: z.boolean().default(microphoneConfigDefault.always_connected),
|
||||||
disconnect_seconds: z
|
disconnect_seconds: z
|
||||||
.number()
|
.number()
|
||||||
.min(0)
|
.min(0)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { log } from '../../src/utils/debug.js';
|
import { log } from '../../src/utils/debug.js';
|
||||||
|
|
||||||
describe('log', () => {
|
describe('log', () => {
|
||||||
|
const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined);
|
||||||
|
afterAll(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
it('should do nothing without debug logging set', () => {
|
it('should do nothing without debug logging set', () => {
|
||||||
const spy = vi.spyOn(global.console, 'debug');
|
|
||||||
log({}, 'foo');
|
log({}, 'foo');
|
||||||
expect(spy).not.toBeCalled();
|
expect(spy).not.toBeCalled();
|
||||||
});
|
});
|
||||||
it('should log debug when appropriately configured', () => {
|
it('should log debug when appropriately configured', () => {
|
||||||
const spy = vi.spyOn(global.console, 'debug');
|
|
||||||
log({ debug: { logging: true } }, 'foo');
|
log({ debug: { logging: true } }, 'foo');
|
||||||
expect(spy).toBeCalledWith('foo');
|
expect(spy).toBeCalledWith('foo');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe('MicrophoneController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
vi.unstubAllGlobals;
|
vi.unstubAllGlobals;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ describe('MicrophoneController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should be forbidden when permission denied', async () => {
|
it('should be forbidden when permission denied', async () => {
|
||||||
|
// Don't actually log messages to the console during the test.
|
||||||
|
vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
|
||||||
const controller = new MicrophoneController();
|
const controller = new MicrophoneController();
|
||||||
navigatorMock.mediaDevices.getUserMedia.mockRejectedValue(new Error());
|
navigatorMock.mediaDevices.getUserMedia.mockRejectedValue(new Error());
|
||||||
await controller.connect();
|
await controller.connect();
|
||||||
|
|||||||
Reference in New Issue
Block a user