= createRef();
@@ -541,6 +548,7 @@ export class FrigateCardLiveCarousel extends LitElement {
`
diff --git a/src/components/views.ts b/src/components/views.ts
index 4c5f17dc..da13525e 100644
--- a/src/components/views.ts
+++ b/src/components/views.ts
@@ -48,6 +48,9 @@ export class FrigateCardViews extends LitElement {
@property({ attribute: false })
public hide?: boolean;
+ @property({ attribute: false })
+ public microphoneStream?: MediaStream;
+
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view') || changedProps.has('config')) {
if (this.view?.is('live') || this._shouldLivePreload()) {
@@ -204,6 +207,7 @@ export class FrigateCardViews extends LitElement {
.liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
+ .microphoneStream=${this.microphoneStream}
class="${classMap(liveClasses)}"
>
diff --git a/src/const.ts b/src/const.ts
index 9e79c0b7..c0df8745 100644
--- a/src/const.ts
+++ b/src/const.ts
@@ -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_SHOW_IMAGE_DURING_LOAD =
`${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;
export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const;
diff --git a/src/editor.ts b/src/editor.ts
index 2e637c7d..ef6a1279 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -147,6 +147,7 @@ import {
CONF_TIMELINE_STYLE,
CONF_LIVE_CONTROLS_TIMELINE_STYLE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
+ CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
} from './const.js';
import { localize } from './localize/localize.js';
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_TITLE = 'live.controls.title';
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_FILTER = 'media_gallery.controls.filter';
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
@@ -994,10 +996,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
configPathShowRecordings: string,
defaultShowRecordings: boolean,
): TemplateResult {
- return html`
- ${this._renderOptionSelector(configPathStyle, this._timelineStyleTypes, {
- label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
- })}
+ return html` ${this._renderOptionSelector(
+ configPathStyle,
+ this._timelineStyleTypes,
+ {
+ label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
+ },
+ )}
${this._renderNumberInput(configPathWindowSeconds, {
label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`),
})}
@@ -1755,6 +1760,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderMenuButton('expand') /* */}
${this._renderMenuButton('timeline')}
${this._renderMenuButton('media_player')}
+ ${this._renderMenuButton('microphone')}
`
: ''}
@@ -1844,6 +1850,15 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_LIVE_LAYOUT_POSITION_X,
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)}
+ `,
+ )}
`
: ''}
diff --git a/src/external/go2rtc/video-rtc.d.ts b/src/external/go2rtc/video-rtc.d.ts
index 3a0af296..edfa8fdf 100644
--- a/src/external/go2rtc/video-rtc.d.ts
+++ b/src/external/go2rtc/video-rtc.d.ts
@@ -12,11 +12,19 @@ export class VideoRTC extends HTMLElement {
video: HTMLVideoElement;
ws: WebSocket | null;
wsURL: string;
- pc: RTCPeerConnection;
+ pc: RTCPeerConnection | null;
connectTS: number;
mseCodecs: string;
src: string | URL;
oninit(): void;
+ send(value: unknown): void;
+ onpcvideo(ev: Event): void;
+ onconnect(): void;
+ ondisconnect(): void;
+ onclose(): void;
+ onopen(): void;
+ onwebrtc(): void;
+ onmessage: Record void>;
}
diff --git a/src/external/go2rtc/video-rtc.js b/src/external/go2rtc/video-rtc.js
index 445aa94d..11ec30be 100644
--- a/src/external/go2rtc/video-rtc.js
+++ b/src/external/go2rtc/video-rtc.js
@@ -27,7 +27,8 @@ export class VideoRTC extends HTMLElement {
"hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra)
"mp4a.40.2", // AAC LC
"mp4a.40.5", // AAC HE
- "opus", // OPUS Chrome
+ "flac", // FLAC (PCM compatible)
+ "opus", // OPUS Chrome, Firefox
];
/**
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index c4d70334..1f7322ec 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -218,6 +218,11 @@
"layout": "Live Layout",
"lazy_load": "Live cameras are lazily loaded",
"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",
"show_image_during_load": "Show still image while the live stream is loading",
"transition_effect": "Live camera transition effect"
@@ -267,6 +272,7 @@
"image": "Image",
"live": "Live",
"media_player": "Send to media player",
+ "microphone": "Microphone",
"priority": "Priority",
"recordings": "Recordings",
"snapshots": "Snapshots",
diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json
index 500571fd..50c6c8ba 100644
--- a/src/localize/languages/it.json
+++ b/src/localize/languages/it.json
@@ -218,6 +218,11 @@
"layout": "Disposizione dal vivo",
"lazy_load": "Le telecamere dal vivo sono pigramente cariche",
"lazy_unload": "Le telecamere dal vivo sono pigramente non caricate",
+ "microphone": {
+ "disconnect_seconds": "",
+ "editor_label": "",
+ "enabled": ""
+ },
"preload": "Precarica Live View in background",
"show_image_during_load": "Mostra un'immagine fissa durante il caricamento del live streaming",
"transition_effect": "Effetto di transizione della telecamera dal vivo"
diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json
index cbb19e24..d746f2ec 100644
--- a/src/localize/languages/pt-BR.json
+++ b/src/localize/languages/pt-BR.json
@@ -218,6 +218,11 @@
"layout": "Layout dinâmico",
"lazy_load": "As câmeras ao vivo são carregadas lentamente",
"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",
"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"
diff --git a/src/scss/button.scss b/src/scss/button.scss
index 3577c19b..c3148296 100644
--- a/src/scss/button.scss
+++ b/src/scss/button.scss
@@ -19,7 +19,7 @@ ha-icon-button.button {
opacity: 1;
}
50% {
- opacity: 0;
+ opacity: 0.6;
}
100% {
opacity: 1;
diff --git a/src/types.ts b/src/types.ts
index f619f1a5..fa1cc018 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -219,6 +219,8 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'image',
'live',
'menu_toggle',
+ 'microphone_mute',
+ 'microphone_unmute',
'recording',
'recordings',
'snapshot',
@@ -409,6 +411,20 @@ const imageBaseConfigSchema = z.object({
* 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;
+
const go2rtcConfigSchema = z.object({
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
stream: z.string().optional(),
@@ -932,6 +948,9 @@ const liveConfigDefault = {
duration_seconds: 2,
},
},
+ microphone: {
+ ...microphoneConfigDefault,
+ },
};
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
@@ -975,6 +994,7 @@ const liveOverridableConfigSchema = z
.boolean()
.default(liveConfigDefault.show_image_during_load),
layout: mediaLayoutConfigSchema.optional(),
+ microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
})
.merge(actionsSchema);
@@ -1037,6 +1057,7 @@ const menuConfigDefault = {
fullscreen: visibleButtonDefault,
expand: hiddenButtonDefault,
media_player: visibleButtonDefault,
+ microphone: hiddenButtonDefault,
recordings: hiddenButtonDefault,
},
button_size: 40,
@@ -1073,6 +1094,7 @@ const menuConfigSchema = z
media_player: visibleButtonSchema.default(
menuConfigDefault.buttons.media_player,
),
+ microphone: hiddenButtonSchema.default(menuConfigDefault.buttons.microphone),
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
})
.default(menuConfigDefault.buttons),
diff --git a/src/utils/microphone.ts b/src/utils/microphone.ts
new file mode 100644
index 00000000..fc138d86
--- /dev/null
+++ b/src/utils/microphone.ts
@@ -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 {
+ try {
+ this._stream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ video: false,
+ });
+ } catch (e) {
+ this._stream = null;
+ }
+ this._setMute();
+ }
+
+ public async disconnect(): Promise {
+ 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);
+ }
+ }
+}
diff --git a/tests/utils/microphone.test.ts b/tests/utils/microphone.test.ts
new file mode 100644
index 00000000..0a3c0aca
--- /dev/null
+++ b/tests/utils/microphone.test.ts
@@ -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();
+ const track = mock();
+ 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();
+ });
+});