Refactor device management in the office (#9656)

Signed-off-by: Anton Alexeyev <alexeyev.anton@gmail.com>
This commit is contained in:
Anton Alexeyev
2025-08-12 11:21:14 +07:00
committed by GitHub
parent b36e64d148
commit ea81ec0cc8
12 changed files with 180 additions and 327 deletions
@@ -1,79 +0,0 @@
<script lang="ts">
import { getSelectedCamId, updateSelectedCamId } from '@hcengineering/media'
import { translate } from '@hcengineering/platform'
import { DropdownLabels, DropdownTextItem, Label, Progress, Toggle } from '@hcengineering/ui'
import { Room } from 'livekit-client'
import love from '../plugin'
import { myPreferences } from '../stores'
import { blurProcessor, getActive, lk, updateBlurRadius } from '../utils'
void Room.getLocalDevices().then(async (devices) => {
devices.forEach((device) => {
if (device.kind === 'videoinput') {
cameras.push({ label: device.label, id: device.deviceId })
}
})
if (cameras.length === 0) {
cameras.push({ label: await translate(love.string.DefaultDevice, {}), id: 'default' })
}
cameras = cameras
})
let cameras: DropdownTextItem[] = []
$: blurRadius = $myPreferences?.blurRadius ?? 0
$: activeCamera = getActive(cameras, lk.getActiveDevice('videoinput'), getSelectedCamId())
</script>
<div class="antiPopup p-4 grid">
<Label label={love.string.Camera} />
<DropdownLabels
items={cameras}
placeholder={love.string.Camera}
enableSearch={false}
justify={'left'}
width={'100%'}
disabled={cameras.length === 0}
selected={activeCamera?.id}
on:selected={async (item) => {
if (item.detail != null && item.detail !== 'default') {
await lk.switchActiveDevice('videoinput', item.detail)
updateSelectedCamId(item.detail)
activeCamera = cameras.find((p) => p.id === item.detail) ?? cameras[0]
}
}}
/>
{#if blurProcessor !== undefined}
<Label label={love.string.Blur} />
<Toggle
showTooltip={{ label: love.string.BlurTooltip }}
on={blurRadius >= 0.5}
on:change={(e) => {
updateBlurRadius(e.detail ? 0.5 : 0)
}}
/>
{#if blurRadius >= 0.5}
<Label label={love.string.BlurRadius} />
<Progress
editable
max={10}
min={0.5}
value={blurRadius}
on:change={(e) => {
const value = Math.round(e.detail * 2) / 2
updateBlurRadius(value)
}}
/>
{/if}
{/if}
</div>
<style lang="scss">
.grid {
display: grid;
grid-template-columns: 1fr auto;
row-gap: 1rem;
column-gap: 1rem;
align-items: center;
}
</style>
@@ -29,20 +29,18 @@
SplitButton,
eventToHTMLElement,
showPopup,
type AnySvelteComponent,
TooltipInstance
} from '@hcengineering/ui'
import view, { Action } from '@hcengineering/view'
import { getActions } from '@hcengineering/view-resources'
import { toggleCamState, toggleMicState } from '@hcengineering/media-resources'
import love from '../plugin'
import { currentRoom, myInfo, myOffice } from '../stores'
import {
isCamAllowed,
isCameraEnabled,
isConnected,
isFullScreen,
isMicAllowed,
isMicEnabled,
isRecording,
isRecordingAvailable,
@@ -53,20 +51,16 @@
leaveRoom,
record,
screenSharing,
setCam,
setMic,
setShare,
startTranscription,
stopTranscription
} from '../utils'
import CamSettingPopup from './CamSettingPopup.svelte'
import CamSettingPopup from './meeting/CamSettingPopup.svelte'
import ControlBarContainer from './ControlBarContainer.svelte'
import MicSettingPopup from './MicSettingPopup.svelte'
import MicSettingPopup from './meeting/MicSettingPopup.svelte'
import RoomAccessPopup from './RoomAccessPopup.svelte'
import RoomLanguageSelector from './RoomLanguageSelector.svelte'
import RoomModal from './RoomModal.svelte'
import ShareSettingPopup from './ShareSettingPopup.svelte'
import { Room as LKRoom } from 'livekit-client'
export let room: Room
export let canMaximize: boolean = true
@@ -81,14 +75,6 @@
$: allowCam = $currentRoom?.type === RoomType.Video
$: allowLeave = $myInfo?.room !== ($myOffice?._id ?? love.ids.Reception)
async function changeMute (): Promise<void> {
await setMic(!$isMicEnabled)
}
async function changeCam (): Promise<void> {
await setCam(!$isCameraEnabled)
}
async function changeShare (): Promise<void> {
const newValue = !$isSharingEnabled
const audio = newValue && $isShareWithSound
@@ -183,11 +169,10 @@
size={'large'}
icon={$isMicEnabled ? love.icon.MicEnabled : love.icon.MicDisabled}
showTooltip={{
label: !$isMicAllowed ? love.string.MicPermission : $isMicEnabled ? love.string.Mute : love.string.UnMute,
label: $isMicEnabled ? love.string.Mute : love.string.UnMute,
keys: micKeys
}}
action={changeMute}
disabled={!$isMicAllowed}
action={toggleMicState}
secondIcon={IconUpOutline}
secondAction={micSettings}
separate
@@ -197,15 +182,10 @@
size={'large'}
icon={$isCameraEnabled ? love.icon.CamEnabled : love.icon.CamDisabled}
showTooltip={{
label: !$isCamAllowed
? love.string.CamPermission
: $isCameraEnabled
? love.string.StopVideo
: love.string.StartVideo,
label: $isCameraEnabled ? love.string.StopVideo : love.string.StartVideo,
keys: camKeys
}}
disabled={!$isCamAllowed}
action={changeCam}
action={toggleCamState}
secondIcon={IconUpOutline}
secondAction={camSettings}
separate
@@ -1,120 +0,0 @@
<script lang="ts">
import core, { getCurrentAccount } from '@hcengineering/core'
import { DevicesPreference } from '@hcengineering/love'
import {
getSelectedMicId,
getSelectedSpeakerId,
updateSelectedMicId,
updateSelectedSpeakerId
} from '@hcengineering/media'
import { translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { DropdownLabels, DropdownTextItem, Label, Toggle } from '@hcengineering/ui'
import { isKrispNoiseFilterSupported } from '@livekit/krisp-noise-filter'
import { Room } from 'livekit-client'
import love from '../plugin'
import { myPreferences } from '../stores'
import { getActive, krispProcessor, lk } from '../utils'
void Room.getLocalDevices().then(async (devices) => {
devices.forEach((device) => {
if (device.deviceId !== 'default') {
if (device.kind === 'audiooutput') {
speakers.push({ label: device.label, id: device.deviceId })
} else if (device.kind === 'audioinput') {
mics.push({ label: device.label, id: device.deviceId })
}
}
})
if (speakers.length === 0) {
speakers.push({ label: await translate(love.string.DefaultDevice, {}), id: 'default' })
}
if (mics.length === 0) {
mics.push({ label: await translate(love.string.DefaultDevice, {}), id: 'default' })
}
speakers = speakers
mics = mics
})
let speakers: DropdownTextItem[] = []
let mics: DropdownTextItem[] = []
$: activeSpeaker = getActive(speakers, lk.getActiveDevice('audiooutput'), getSelectedSpeakerId())
$: activeMic = getActive(mics, lk.getActiveDevice('audioinput'), getSelectedMicId())
const client = getClient()
async function saveNoiseCancellationPreference (
myPreferences: DevicesPreference | undefined,
value: boolean
): Promise<void> {
if (myPreferences !== undefined) {
await client.update(myPreferences, { noiseCancellation: value })
} else {
const acc = getCurrentAccount().uuid
await client.createDoc(love.class.DevicesPreference, core.space.Workspace, {
attachedTo: acc,
noiseCancellation: value,
camEnabled: true,
micEnabled: true,
blurRadius: 0
})
}
await krispProcessor.setEnabled(value)
}
</script>
<div class="antiPopup p-4 grid">
<Label label={love.string.Speaker} />
<DropdownLabels
selected={activeSpeaker?.id}
items={speakers}
enableSearch={false}
justify={'left'}
width={'100%'}
disabled={speakers.length === 0}
on:selected={async (item) => {
if (item.detail != null && item.detail !== 'default') {
await lk.switchActiveDevice('audiooutput', item.detail)
updateSelectedSpeakerId(item.detail)
activeSpeaker = speakers.find((p) => p.id === item.detail) ?? speakers[0]
}
}}
/>
<Label label={love.string.Microphone} />
<DropdownLabels
items={mics}
placeholder={love.string.Microphone}
selected={activeMic?.id}
enableSearch={false}
justify={'left'}
disabled={mics.length === 0}
width={'100%'}
on:selected={async (item) => {
if (item.detail != null && item.detail !== 'default') {
await lk.switchActiveDevice('audioinput', item.detail)
updateSelectedMicId(item.detail)
activeMic = mics.find((p) => p.id === item.detail) ?? mics[0]
}
}}
/>
{#if isKrispNoiseFilterSupported()}
<Label label={love.string.NoiseCancellation} />
<Toggle
on={$myPreferences?.noiseCancellation ?? true}
on:change={(e) => {
saveNoiseCancellationPreference($myPreferences, e.detail)
}}
/>
{/if}
</div>
<style lang="scss">
.grid {
display: grid;
grid-template-columns: 1fr auto;
row-gap: 1rem;
column-gap: 1rem;
align-items: center;
}
</style>
@@ -45,28 +45,25 @@
import { getClient } from '@hcengineering/presentation'
import view from '@hcengineering/view'
import { getObjectLinkFragment } from '@hcengineering/view-resources'
import { toggleCamState, toggleMicState } from '@hcengineering/media-resources'
import { createEventDispatcher } from 'svelte'
import love from '../plugin'
import { currentMeetingMinutes, currentRoom, infos, invites, myInfo, myOffice, myRequests, rooms } from '../stores'
import {
endMeeting,
getRoomName,
isCamAllowed,
isCameraEnabled,
isConnected,
isMicAllowed,
isMicEnabled,
isShareWithSound,
isSharingEnabled,
leaveRoom,
screenSharing,
setCam,
setMic,
setShare,
tryConnect
} from '../utils'
import CamSettingPopup from './CamSettingPopup.svelte'
import MicSettingPopup from './MicSettingPopup.svelte'
import CamSettingPopup from './meeting/CamSettingPopup.svelte'
import MicSettingPopup from './meeting/MicSettingPopup.svelte'
import RoomAccessPopup from './RoomAccessPopup.svelte'
import ShareSettingPopup from './ShareSettingPopup.svelte'
@@ -98,14 +95,6 @@
const dispatch = createEventDispatcher()
async function changeMute (): Promise<void> {
await setMic(!$isMicEnabled)
}
async function changeCam (): Promise<void> {
await setCam(!$isCameraEnabled)
}
async function changeShare (): Promise<void> {
const newValue = !$isSharingEnabled
const audio = newValue && $isShareWithSound
@@ -206,10 +195,9 @@
icon={$isMicEnabled ? love.icon.MicEnabled : love.icon.MicDisabled}
label={$isMicEnabled ? love.string.Mute : love.string.UnMute}
showTooltip={{
label: !$isMicAllowed ? love.string.MicPermission : $isMicEnabled ? love.string.Mute : love.string.UnMute
label: $isMicEnabled ? love.string.Mute : love.string.UnMute
}}
disabled={!$isMicAllowed}
action={changeMute}
action={toggleMicState}
secondIcon={IconUpOutline}
secondAction={micSettings}
separate
@@ -220,14 +208,9 @@
icon={$isCameraEnabled ? love.icon.CamEnabled : love.icon.CamDisabled}
label={$isCameraEnabled ? love.string.StopVideo : love.string.StartVideo}
showTooltip={{
label: !$isCamAllowed
? love.string.CamPermission
: $isCameraEnabled
? love.string.StopVideo
: love.string.StartVideo
label: $isCameraEnabled ? love.string.StopVideo : love.string.StartVideo
}}
disabled={!$isCamAllowed}
action={changeCam}
action={toggleCamState}
secondIcon={IconUpOutline}
secondAction={camSettings}
separate
@@ -0,0 +1,52 @@
<script lang="ts">
import { Component, Label, Progress, Toggle } from '@hcengineering/ui'
import love from '../../plugin'
import { myPreferences } from '../../stores'
import { blurProcessor, updateBlurRadius } from '../../utils'
import mediaPlugin from '@hcengineering/media'
$: blurRadius = $myPreferences?.blurRadius ?? 0
</script>
<div class="antiPopup mediaPopup">
<Component is={mediaPlugin.component.MediaPopupCamSelector} />
{#if blurProcessor !== undefined}
<div class="grid p-3">
<Label label={love.string.Blur} />
<Toggle
showTooltip={{ label: love.string.BlurTooltip }}
on={blurRadius >= 0.5}
on:change={(e) => {
updateBlurRadius(e.detail ? 0.5 : 0)
}}
/>
{#if blurRadius >= 0.5}
<Label label={love.string.BlurRadius} />
<div class="separator" />
<Progress
editable
max={10}
min={0.5}
value={blurRadius}
on:change={(e) => {
const value = Math.round(e.detail * 2) / 2
updateBlurRadius(value)
}}
/>
{/if}
</div>
{/if}
</div>
<style lang="scss">
.mediaPopup {
width: 20rem;
}
.grid {
display: grid;
grid-template-columns: 1fr auto;
row-gap: 1rem;
column-gap: 1rem;
align-items: center;
}
</style>
@@ -0,0 +1,61 @@
<script lang="ts">
import core, { getCurrentAccount } from '@hcengineering/core'
import { DevicesPreference } from '@hcengineering/love'
import { getClient } from '@hcengineering/presentation'
import { Component, Label, Toggle } from '@hcengineering/ui'
import { isKrispNoiseFilterSupported } from '@livekit/krisp-noise-filter'
import love from '../../plugin'
import { myPreferences } from '../../stores'
import { krispProcessor } from '../../utils'
import mediaPlugin from '@hcengineering/media'
const client = getClient()
async function saveNoiseCancellationPreference (
myPreferences: DevicesPreference | undefined,
value: boolean
): Promise<void> {
if (myPreferences !== undefined) {
await client.update(myPreferences, { noiseCancellation: value })
} else {
const acc = getCurrentAccount().uuid
await client.createDoc(love.class.DevicesPreference, core.space.Workspace, {
attachedTo: acc,
noiseCancellation: value,
camEnabled: true,
micEnabled: true,
blurRadius: 0
})
}
await krispProcessor.setEnabled(value)
}
</script>
<div class="antiPopup mediaPopup">
<Component is={mediaPlugin.component.MediaPopupMicSelector} />
<Component is={mediaPlugin.component.MediaPopupSpkSelector} />
{#if isKrispNoiseFilterSupported()}
<div class="grid p-3">
<Label label={love.string.NoiseCancellation} />
<Toggle
on={$myPreferences?.noiseCancellation ?? true}
on:change={(e) => {
saveNoiseCancellationPreference($myPreferences, e.detail)
}}
/>
</div>
{/if}
</div>
<style lang="scss">
.mediaPopup {
width: 20rem;
}
.grid {
display: grid;
grid-template-columns: 1fr auto;
row-gap: 1rem;
column-gap: 1rem;
align-items: center;
}
</style>
+17 -57
View File
@@ -40,8 +40,8 @@ import {
RoomType,
TranscriptionStatus
} from '@hcengineering/love'
import { getSelectedCamId, getSelectedMicId, getSelectedSpeakerId } from '@hcengineering/media'
import { useMedia } from '@hcengineering/media-resources'
import { getSelectedSpeakerId } from '@hcengineering/media'
import { getSelectedCam, getSelectedMic, getSelectedSpeaker, useMedia } from '@hcengineering/media-resources'
import { getEmbeddedLabel, getMetadata, getResource, type IntlString } from '@hcengineering/platform'
import presentation, {
copyTextToClipboard,
@@ -50,14 +50,7 @@ import presentation, {
getClient,
type ObjectSearchResult
} from '@hcengineering/presentation'
import {
closePanel,
type DropdownTextItem,
getCurrentLocation,
navigate,
panelstore,
showPopup
} from '@hcengineering/ui'
import { closePanel, getCurrentLocation, navigate, panelstore, showPopup } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { getObjectLinkFragment } from '@hcengineering/view-resources'
import { type Widget, type WidgetTab } from '@hcengineering/workbench'
@@ -192,8 +185,6 @@ export const isCameraEnabled = writable<boolean>(false)
export const isSharingEnabled = writable<boolean>(false)
export const isFullScreen = writable<boolean>(false)
export const isShareWithSound = writable<boolean>(false)
export const isMicAllowed = writable<boolean>(false)
export const isCamAllowed = writable<boolean>(false)
export const currentRoomAudioLevels = writable<Map<Ref<Person>, number>>(new Map())
@@ -640,21 +631,16 @@ export async function setCam (value: boolean): Promise<void> {
if ($isCurrentInstanceConnected) {
try {
const opt: VideoCaptureOptions = {}
const selectedDevice = getSelectedCamId()
const devices = await LKRoom.getLocalDevices('videoinput')
isCamAllowed.set(devices.length > 0)
if (selectedDevice !== null) {
const available = devices.find((p) => p.deviceId === selectedDevice)
if (available !== undefined) {
// We need to use exact device id to avoid issues with Firefox
// that can switch to another device when using just deviceId
opt.deviceId = { exact: available.deviceId }
}
const selectedDevice = await getSelectedCam()
if (selectedDevice !== null && selectedDevice !== undefined) {
// We need to use exact device id to avoid issues with Firefox
// that can switch to another device when using just deviceId
opt.deviceId = { exact: selectedDevice.deviceId }
await lk.switchActiveDevice('videoinput', selectedDevice.deviceId)
}
await lk.localParticipant.setCameraEnabled(value, opt)
} catch (err) {
console.error(err)
isCamAllowed.set(false)
}
} else {
sendMessage({ type: 'set_cam', value })
@@ -664,34 +650,24 @@ export async function setCam (value: boolean): Promise<void> {
export async function setMic (value: boolean): Promise<void> {
if ($isCurrentInstanceConnected) {
try {
const speaker = getSelectedSpeakerId()
if (speaker !== undefined) {
const devices = await LKRoom.getLocalDevices('audiooutput')
const available = devices.find((p) => p.deviceId === speaker)
if (available !== undefined) {
await lk.switchActiveDevice('audiooutput', speaker)
}
const speaker = await getSelectedSpeaker()
if (speaker !== null && speaker !== undefined) {
await lk.switchActiveDevice('audiooutput', speaker.deviceId)
}
} catch (err) {
console.error(err)
}
try {
const opt: AudioCaptureOptions = {}
const selectedDevice = getSelectedMicId()
const devices = await LKRoom.getLocalDevices('audioinput')
isMicAllowed.set(devices.length > 0)
if (selectedDevice !== null) {
const available = devices.find((p) => p.deviceId === selectedDevice)
if (available !== undefined) {
// We need to use exact device id to avoid issues with Firefox
// that can switch to another device when using just deviceId
opt.deviceId = { exact: available.deviceId }
}
const selectedDevice = await getSelectedMic()
if (selectedDevice !== null && selectedDevice !== undefined) {
// We need to use exact device id to avoid issues with Firefox
// that can switch to another device when using just deviceId
opt.deviceId = { exact: selectedDevice.deviceId }
}
await lk.localParticipant.setMicrophoneEnabled(value, opt)
} catch (err) {
console.error(err)
isMicAllowed.set(false)
}
} else {
sendMessage({ type: 'set_mic', value })
@@ -973,22 +949,6 @@ export async function invite (person: Ref<Person>, room: Ref<Room> | undefined):
})
}
export function getActive (
devices: DropdownTextItem[],
activeId: string | undefined,
prevId?: string | null
): DropdownTextItem {
if (activeId !== undefined) {
const res = devices.find((p) => p.id === activeId)
if (res !== undefined) return res
}
if (prevId != null) {
const res = devices.find((p) => p.id === prevId)
if (res !== undefined) return res
}
return devices[0]
}
export async function toggleMic (): Promise<void> {
await setMic(!get(isMicEnabled))
}
@@ -17,26 +17,19 @@
import { Icon, tooltip } from '@hcengineering/ui'
import media from '../plugin'
import { sessions } from '../stores'
import IconCamOn from './icons/CamOn.svelte'
import IconCamOff from './icons/CamOff.svelte'
import { toggleCamState } from '../utils'
export let state: CamState | undefined
function handleClick (): void {
const enabled = state?.enabled ?? false
for (const session of $sessions) {
session.emit('camera', !enabled)
}
}
</script>
{#if state != null}
<button
class="hulyStatusBarButton mini positive {state.enabled ? 'positiveContent' : 'negativeContent'}"
use:tooltip={{ label: state.enabled ? media.string.TurnOffCam : media.string.TurnOnCam, direction: 'bottom' }}
on:click={handleClick}
on:click={toggleCamState}
>
<Icon icon={state.enabled ? IconCamOn : IconCamOff} size={'small'} />
</button>
@@ -17,26 +17,19 @@
import { Icon, tooltip } from '@hcengineering/ui'
import media from '../plugin'
import { sessions } from '../stores'
import IconMicOn from './icons/MicOn.svelte'
import IconMicOff from './icons/MicOff.svelte'
import { toggleMicState } from '../utils'
export let state: MicState | undefined
function handleClick (): void {
const enabled = state?.enabled ?? false
for (const session of $sessions) {
session.emit('microphone', !enabled)
}
}
</script>
{#if state != null}
<button
class="hulyStatusBarButton mini positive {state.enabled ? 'positiveContent' : 'negativeContent'}"
use:tooltip={{ label: state.enabled ? media.string.TurnOffMic : media.string.TurnOnMic, direction: 'bottom' }}
on:click={handleClick}
on:click={toggleMicState}
>
<Icon icon={state.enabled ? IconMicOn : IconMicOff} size={'small'} />
</button>
+6
View File
@@ -17,6 +17,9 @@ import { type Resources } from '@hcengineering/platform'
import MediaExt from './components/MediaExt.svelte'
import WorkbenchExtension from './components/WorkbenchExtension.svelte'
import MediaPopupCamSelector from './components/MediaPopupCamSelector.svelte'
import MediaPopupMicSelector from './components/MediaPopupMicSelector.svelte'
import MediaPopupSpkSelector from './components/MediaPopupSpkSelector.svelte'
export * from './stores'
export * from './utils'
@@ -24,6 +27,9 @@ export * from './utils'
export default async (): Promise<Resources> => ({
component: {
MediaExt,
MediaPopupCamSelector,
MediaPopupMicSelector,
MediaPopupSpkSelector,
WorkbenchExtension
}
})
+20 -1
View File
@@ -29,7 +29,8 @@ import { type IntlString, getEmbeddedLabel } from '@hcengineering/platform'
import EventEmitter from 'events'
import { onDestroy } from 'svelte'
import type TypedEventEmitter from 'typed-emitter'
import { registerSession, unregisterSession } from './stores'
import { registerSession, sessions, state, unregisterSession } from './stores'
import { get } from 'svelte/store'
/** @public */
export async function getSelectedMic (): Promise<MediaDeviceInfo | undefined> {
@@ -67,6 +68,16 @@ export function getDeviceLabel (device: MediaDeviceInfo): IntlString {
return getEmbeddedLabel(cleanupDeviceLabel(device.label))
}
/** @public */
export function toggleCamState (): void {
toggleDeviceState('camera')
}
/** @public */
export function toggleMicState (): void {
toggleDeviceState('microphone')
}
export interface UseMediaOptions {
state: MediaState
autoDestroy?: boolean
@@ -86,6 +97,14 @@ export function useMedia (options: UseMediaOptions): MediaSession {
return session
}
function toggleDeviceState (kind: 'camera' | 'microphone'): void {
const deviceState: CamState | MicState | undefined = kind === 'camera' ? get(state).camera : get(state).microphone
const enabled = deviceState?.enabled ?? false
for (const session of get(sessions)) {
session.emit(kind, !enabled)
}
}
class MediaSessionImpl
extends (EventEmitter as unknown as new () => TypedEventEmitter<MediaSessionEvents>)
implements MediaSession {
+6 -1
View File
@@ -15,12 +15,17 @@
import type { Plugin, Asset, IntlString } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import { type ComponentExtensionId } from '@hcengineering/ui'
import { type AnyComponent, type ComponentExtensionId } from '@hcengineering/ui'
/** @public */
export const mediaId = 'media' as Plugin
export const mediaPlugin = plugin(mediaId, {
component: {
MediaPopupCamSelector: '' as AnyComponent,
MediaPopupMicSelector: '' as AnyComponent,
MediaPopupSpkSelector: '' as AnyComponent
},
extension: {
StateIndicator: '' as ComponentExtensionId,
StateContext: '' as ComponentExtensionId