diff --git a/src/card.ts b/src/card.ts index ef892399..4f90fc23 100644 --- a/src/card.ts +++ b/src/card.ts @@ -620,6 +620,7 @@ class FrigateCard extends LitElement { if (this._microphoneController && cameraCapabilities?.supports2WayAudio) { const muted = this._microphoneController.isMuted(); + const buttonType = this._getConfig().menu.buttons.microphone.type; buttons.push({ icon: this._microphoneController.isForbidden() ? 'mdi:microphone-message-off' @@ -630,12 +631,21 @@ class FrigateCard extends LitElement { 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, + ...(buttonType === 'momentary' && { + start_tap_action: createFrigateCardCustomAction( + 'microphone_unmute', + ) as FrigateCardCustomAction, + end_tap_action: createFrigateCardCustomAction( + 'microphone_mute', + ) as FrigateCardCustomAction, + }), + ...(buttonType === 'toggle' && { + tap_action: createFrigateCardCustomAction( + this._microphoneController.isMuted() + ? 'microphone_unmute' + : 'microphone_mute', + ) as FrigateCardCustomAction, + }), }); } @@ -1577,17 +1587,24 @@ class FrigateCard extends LitElement { 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); + // The connect() call is async and make take an arbitrary amount of + // time for the user to grant access to their microphone. With a + // momentary microphone button the mute call (on mouse release) may + // arrive before the connection is even granted, so we unmute first + // before the connection is made, so the mute call on release will not + // be 'overwritten' incorrectly. + this._microphoneController?.unmute(); + + // Must requestUpdate to show the correct microphone state in the + // menu. + this._microphoneController?.connect().then(() => this.requestUpdate()); } else if (this._microphoneController?.isConnected()) { - unmuteAndUpdate(); + this._microphoneController.unmute(); + this.requestUpdate(); } break; default: diff --git a/src/editor.ts b/src/editor.ts index b76ebc07..21aa0b02 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -181,14 +181,14 @@ import { const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; +const MENU_CAMERAS_ENGINE = 'cameras.engine'; const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc'; const MENU_CAMERAS_IMAGE = 'cameras.image'; +const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider'; const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye'; const MENU_CAMERAS_TRIGGERS = 'cameras.triggers'; const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card'; -const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider'; -const MENU_CAMERAS_ENGINE = 'cameras.engine'; const MENU_IMAGE_LAYOUT = 'image.layout'; const MENU_LIVE_CONTROLS = 'live.controls'; const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous'; @@ -197,18 +197,18 @@ 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_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails'; const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls'; const MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS = 'media_viewer.controls.next_previous'; const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails'; const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline'; const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title'; const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout'; -const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; const MENU_OPTIONS = 'options'; const MENU_PERFORMANCE_FEATURES = 'performance.features'; const MENU_PERFORMANCE_STYLE = 'performance.style'; +const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails'; const MENU_VIEW_SCAN = 'scan'; interface EditorOptionsSet { @@ -536,6 +536,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'mjpeg', label: localize('config.cameras.go2rtc.modes.mjpeg') }, ]; + protected _microphoneButtonTypes: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'momentary', label: localize('config.menu.buttons.types.momentary') }, + { value: 'toggle', label: localize('config.menu.buttons.types.toggle') }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -841,7 +847,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor * @param button The name of the button. * @returns A rendered template. */ - protected _renderMenuButton(button: string): TemplateResult { + protected _renderMenuButton( + button: string, + additionalOptions?: TemplateResult, + ): TemplateResult { const menuButtonAlignments: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'matching', label: localize('config.menu.buttons.alignments.matching') }, @@ -892,6 +901,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, { label: localize('config.menu.buttons.icon'), })} + ${additionalOptions} ` : ''} @@ -1763,7 +1773,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderMenuButton('expand') /* */} ${this._renderMenuButton('timeline')} ${this._renderMenuButton('media_player')} - ${this._renderMenuButton('microphone')} + ${this._renderMenuButton( + 'microphone', + html`${this._renderOptionSelector( + `${CONF_MENU_BUTTONS}.microphone.type`, + this._microphoneButtonTypes, + { label: localize('config.menu.buttons.type') }, + )}`, + )} ` : ''} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index a035bc09..cfbaeb7e 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -280,7 +280,12 @@ "recordings": "Recordings", "snapshots": "Snapshots", "substreams": "Substream(s)", - "timeline": "Timeline" + "timeline": "Timeline", + "type": "Button type", + "types": { + "momentary": "Momentary", + "toggle": "Toggle" + } }, "position": "Menu position", "positions": { diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 2a20f25c..cfa9df35 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -277,7 +277,12 @@ "priority": "Priorità", "snapshots": "Istantanee", "substreams": "Flusso/i secondario/i", - "timeline": "Timeline" + "timeline": "Timeline", + "type": "", + "types": { + "momentary": "", + "toggle": "" + } }, "position": "Posizione del menu", "positions": { diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index e9bda5c3..55a31aa8 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -279,7 +279,12 @@ "recordings": "Gravações", "snapshots": "Instantâneos", "substreams": "Substream(s)", - "timeline": "Linha do tempo" + "timeline": "Linha do tempo", + "type": "", + "types": { + "momentary": "", + "toggle": "" + } }, "position": "Posição do menu", "positions": { diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index ca4fe617..3ea3ad98 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -265,7 +265,12 @@ "priority": "Prioridade", "snapshots": "Instantâneos", "substreams": "substreams", - "timeline": "Linha do tempo" + "timeline": "Linha do tempo", + "type": "", + "types": { + "momentary": "", + "toggle": "" + } }, "position": "Posição do menu", "positions": { diff --git a/src/types.ts b/src/types.ts index 5580f896..41f15280 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1063,7 +1063,10 @@ const menuConfigDefault = { fullscreen: visibleButtonDefault, expand: hiddenButtonDefault, media_player: visibleButtonDefault, - microphone: hiddenButtonDefault, + microphone: { + ...hiddenButtonDefault, + type: 'momentary' as const, + }, recordings: hiddenButtonDefault, }, button_size: 40, @@ -1100,7 +1103,13 @@ const menuConfigSchema = z media_player: visibleButtonSchema.default( menuConfigDefault.buttons.media_player, ), - microphone: hiddenButtonSchema.default(menuConfigDefault.buttons.microphone), + microphone: hiddenButtonSchema + .extend({ + type: z + .enum(['momentary', 'toggle']) + .default(menuConfigDefault.buttons.microphone.type), + }) + .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 index fc138d86..772b45f1 100644 --- a/src/utils/microphone.ts +++ b/src/utils/microphone.ts @@ -1,3 +1,5 @@ +import { errorToConsole } from "./basic"; + export class MicrophoneController { protected _stream?: MediaStream | null; protected _timerID: number | null = null; @@ -19,7 +21,8 @@ export class MicrophoneController { audio: true, video: false, }); - } catch (e) { + } catch (e: unknown) { + errorToConsole(e as Error); this._stream = null; } this._setMute();