Mic Selector
Microphone picker: outline trigger, searchable popover list, cleaned-up device names with the hardware id muted.
import { MicSelector, MicSelectorContent, MicSelectorEmpty, MicSelectorInput, MicSelectorItem, MicSelectorLabel, MicSelectorList, MicSelectorTrigger, MicSelectorValue,} from "@/components/ai/mic-selector";import { useState } from "react";import { IconPlaceholder } from "@/edmi/icon-placeholder";
// Fake devices so the demo needs no microphone permission. Without `devices`, the component lists the real inputs.const device = (deviceId: string, label: string) => ({ deviceId, groupId: deviceId, kind: "audioinput", label, toJSON: () => ({}), }) as MediaDeviceInfo;
const devices = [ device("builtin", "MacBook Pro Microphone (05ac:8104)"), device("airpods", "AirPods Pro (1a2b:3c4d)"), device("shure", "Shure MV7 (14ed:1012)"),];
const Mic = () => ( <IconPlaceholder lucide="MicIcon" tabler="IconMicrophone" hugeicons="VoiceIcon" phosphor="MicrophoneIcon" remixicon="RiMicLine" className="size-4 shrink-0" />);
export default function Demo() { const [value, setValue] = useState<string | undefined>("builtin"); return ( <MicSelector devices={devices} value={value} onValueChange={setValue}> <MicSelectorTrigger className="w-[260px]"> <Mic /> <MicSelectorValue /> </MicSelectorTrigger> <MicSelectorContent> <MicSelectorInput /> <MicSelectorList> {(items) => ( <> <MicSelectorEmpty /> {items.map((d) => ( <MicSelectorItem key={d.deviceId} value={d.deviceId}> <Mic /> <MicSelectorLabel device={d} /> </MicSelectorItem> ))} </> )} </MicSelectorList> </MicSelectorContent> </MicSelector> );}<script setup lang="ts">import { AudioLinesIcon } from "@lucide/vue";import { MicSelector, MicSelectorContent, MicSelectorEmpty, MicSelectorInput, MicSelectorItem, MicSelectorLabel, MicSelectorList, MicSelectorTrigger, MicSelectorValue,} from "@/components/ai/mic-selector";import { ref } from "vue";
// Fake devices so the demo needs no microphone permission. Without `devices`, the component lists the real inputs.const device = (deviceId: string, label: string) => ({ deviceId, groupId: deviceId, kind: "audioinput", label, toJSON: () => ({}) }) as MediaDeviceInfo;
const devices = [ device("builtin", "MacBook Pro Microphone (05ac:8104)"), device("airpods", "AirPods Pro (1a2b:3c4d)"), device("shure", "Shure MV7 (14ed:1012)"),];
const value = ref<string | undefined>("builtin");</script>
<template> <MicSelector v-model:value="value" :devices="devices"> <MicSelectorTrigger class="w-[260px]"> <AudioLinesIcon class="size-4 shrink-0" /> <MicSelectorValue /> </MicSelectorTrigger> <MicSelectorContent> <MicSelectorInput /> <MicSelectorList v-slot="{ devices: items }"> <MicSelectorEmpty /> <MicSelectorItem v-for="d in items" :key="d.deviceId" :value="d.deviceId"> <AudioLinesIcon /> <MicSelectorLabel :device="d" /> </MicSelectorItem> </MicSelectorList> </MicSelectorContent> </MicSelector></template><script lang="ts"> import IconPlaceholder from "$lib/components/icon-placeholder/icon-placeholder.svelte"; import { MicSelector, MicSelectorContent, MicSelectorEmpty, MicSelectorInput, MicSelectorItem, MicSelectorLabel, MicSelectorList, MicSelectorTrigger, MicSelectorValue, } from "$lib/components/ai/mic-selector";
// Fake devices so the demo needs no microphone permission. Without `devices`, the component lists the real inputs. const device = (deviceId: string, label: string) => ({ deviceId, groupId: deviceId, kind: "audioinput", label, toJSON: () => ({}) }) as MediaDeviceInfo;
const devices = [ device("builtin", "MacBook Pro Microphone (05ac:8104)"), device("airpods", "AirPods Pro (1a2b:3c4d)"), device("shure", "Shure MV7 (14ed:1012)"), ];
let value = $state<string | undefined>("builtin");</script>
{#snippet mic()} <IconPlaceholder lucide="MicIcon" tabler="IconMicrophone" hugeicons="VoiceIcon" phosphor="MicrophoneIcon" remixicon="RiMicLine" class="size-4 shrink-0" />{/snippet}
<MicSelector {devices} bind:value> <MicSelectorTrigger class="w-[260px]"> {@render mic()} <MicSelectorValue /> </MicSelectorTrigger> <MicSelectorContent> <MicSelectorInput /> <MicSelectorList> {#snippet children(items)} <MicSelectorEmpty /> {#each items as d (d.deviceId)} <MicSelectorItem value={d.deviceId} keywords={[d.label]}> {@render mic()} <MicSelectorLabel device={d} /> </MicSelectorItem> {/each} {/snippet} </MicSelectorList> </MicSelectorContent></MicSelector>Built on popover and command.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-mic-selectorpnpm dlx shadcn@latest add @edmi-ui/ai-mic-selectoryarn dlx shadcn@latest add @edmi-ui/ai-mic-selectorbunx shadcn@latest add @edmi-ui/ai-mic-selectorPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,command,popover.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-mic-selectorpnpm dlx shadcn-vue@latest add @edmi-ui/ai-mic-selectoryarn dlx shadcn-vue@latest add @edmi-ui/ai-mic-selectorbunx shadcn-vue@latest add @edmi-ui/ai-mic-selectorPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @vueuse/core @lucide/vue reka-uiTerminal window pnpm add @vueuse/core @lucide/vue reka-uiTerminal window yarn add @vueuse/core @lucide/vue reka-uiTerminal window bun add @vueuse/core @lucide/vue reka-uiAdd the Edmi components this one builds on:
button,command,popover.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-mic-selector.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-mic-selector.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-mic-selector.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-mic-selector.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,command,popover.Copy the code from Component source into your project and update the import paths to match your aliases.
import { MicSelector, MicSelectorContent, MicSelectorInput, MicSelectorList, MicSelectorTrigger, MicSelectorValue,} from "@/components/ai/mic-selector";
<MicSelector> <MicSelectorTrigger className="w-[260px]"> <Mic /> <MicSelectorValue /> </MicSelectorTrigger> <MicSelectorContent> <MicSelectorInput /> <MicSelectorList> </MicSelectorList> </MicSelectorContent></MicSelector><script setup lang="ts">import { MicSelector, MicSelectorContent, MicSelectorEmpty, MicSelectorInput, MicSelectorItem, MicSelectorLabel, MicSelectorList, MicSelectorTrigger, MicSelectorValue,} from "@/components/ai/mic-selector";</script>
<template> <MicSelector> <MicSelectorTrigger class="w-[260px]"> <AudioLinesIcon class="size-4 shrink-0" /> <MicSelectorValue /> </MicSelectorTrigger> <MicSelectorContent> <MicSelectorInput /> <MicSelectorList v-slot=""> <MicSelectorEmpty /> <MicSelectorItem v-for="d in items" :key="d.deviceId" :value="d.deviceId"> <AudioLinesIcon /> <MicSelectorLabel :device="d" /> </MicSelectorItem> </MicSelectorList> </MicSelectorContent> </MicSelector></template><script lang="ts"> import { MicSelector, MicSelectorContent, MicSelectorEmpty, MicSelectorInput, MicSelectorItem, MicSelectorLabel, MicSelectorList, MicSelectorTrigger, MicSelectorValue, } from "$lib/components/ai/mic-selector";</script>
<MicSelector bind:value> <MicSelectorTrigger class="w-[260px]"> <MicSelectorValue /> </MicSelectorTrigger> <MicSelectorContent> <MicSelectorInput /> <MicSelectorList> {#snippet children(items)} <MicSelectorEmpty /> {#each items as d (d.deviceId)} <MicSelectorItem value={d.deviceId} keywords={[d.label]}> <MicSelectorLabel device={d} /> </MicSelectorItem> {/each} {/snippet} </MicSelectorList> </MicSelectorContent></MicSelector>Component source
Section titled “Component source”"use client";
import { cn } from "cn";import type { ComponentProps, ReactNode } from "react";import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState,} from "react";import { CaretUpDownIcon } from "@phosphor-icons/react";import { useControllableState } from "@/registry/edmi/hooks/ai/use-controllable-state";import { Button } from "@/registry/edmi/ui/button";import { Command, CommandEmpty, CommandInput, CommandItem, CommandList,} from "@/registry/edmi/ui/command";import { Popover, PopoverContent, PopoverTrigger,} from "@/registry/edmi/ui/popover";
// Built on ui/popover + ui/command (DESIGN §5b): outline trigger, searchable list, hardware id in muted mono.
const deviceIdRegex = /\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/;
interface MicSelectorContextType { data: MediaDeviceInfo[]; value: string | undefined; onValueChange?: (value: string) => void; open: boolean; onOpenChange?: (open: boolean) => void; width: number; setWidth?: (width: number) => void;}
const MicSelectorContext = createContext<MicSelectorContextType>({ data: [], onOpenChange: undefined, onValueChange: undefined, open: false, setWidth: undefined, value: undefined, width: 200,});
export const useAudioDevices = () => { const [devices, setDevices] = useState<MediaDeviceInfo[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [hasPermission, setHasPermission] = useState(false);
const loadDevicesWithoutPermission = useCallback(async () => { try { setLoading(true); setError(null);
const deviceList = await navigator.mediaDevices.enumerateDevices(); const audioInputs = deviceList.filter( (device) => device.kind === "audioinput", );
setDevices(audioInputs); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : "Failed to get audio devices";
setError(message); console.error("Error getting audio devices:", message); } finally { setLoading(false); } }, []);
const loadDevicesWithPermission = useCallback(async () => { if (loading) { return; }
try { setLoading(true); setError(null);
const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true, });
for (const track of tempStream.getTracks()) { track.stop(); }
const deviceList = await navigator.mediaDevices.enumerateDevices(); const audioInputs = deviceList.filter( (device) => device.kind === "audioinput", );
setDevices(audioInputs); setHasPermission(true); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : "Failed to get audio devices";
setError(message); console.error("Error getting audio devices:", message); } finally { setLoading(false); } }, [loading]);
useEffect(() => { loadDevicesWithoutPermission(); }, [loadDevicesWithoutPermission]);
useEffect(() => { const handleDeviceChange = () => { if (hasPermission) { loadDevicesWithPermission(); } else { loadDevicesWithoutPermission(); } };
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return () => { navigator.mediaDevices.removeEventListener( "devicechange", handleDeviceChange, ); }; }, [hasPermission, loadDevicesWithPermission, loadDevicesWithoutPermission]);
return { devices, error, hasPermission, loadDevices: loadDevicesWithPermission, loading, };};
export type MicSelectorProps = ComponentProps<typeof Popover> & { defaultValue?: string; value?: string | undefined; onValueChange?: (value: string | undefined) => void; open?: boolean; onOpenChange?: (open: boolean) => void; /** * Devices to list. When omitted the component enumerates the audio inputs itself (and asks for * microphone permission the first time the list opens). */ devices?: MediaDeviceInfo[];};
export const MicSelector = ({ defaultValue, value: controlledValue, onValueChange: controlledOnValueChange, defaultOpen = false, open: controlledOpen, onOpenChange: controlledOnOpenChange, devices: devicesProp, ...props}: MicSelectorProps) => { const [value, onValueChange] = useControllableState<string | undefined>({ defaultProp: defaultValue, onChange: controlledOnValueChange, prop: controlledValue, }); const [open, onOpenChange] = useControllableState({ defaultProp: defaultOpen, onChange: controlledOnOpenChange, prop: controlledOpen, }); const [width, setWidth] = useState(200); const { devices: enumerated, loading, hasPermission, loadDevices, } = useAudioDevices(); const devices = devicesProp ?? enumerated;
useEffect(() => { if (!devicesProp && open && !hasPermission && !loading) { loadDevices(); } }, [devicesProp, open, hasPermission, loading, loadDevices]);
const contextValue = useMemo( () => ({ data: devices, onOpenChange, onValueChange, open, setWidth, value, width, }), [devices, onOpenChange, onValueChange, open, value, width], );
return ( <MicSelectorContext.Provider value={contextValue}> <Popover {...props} onOpenChange={onOpenChange} open={open} /> </MicSelectorContext.Provider> );};
export type MicSelectorTriggerProps = ComponentProps<typeof PopoverTrigger>;
export const MicSelectorTrigger = ({ children, className, ...props}: MicSelectorTriggerProps) => { const { setWidth } = useContext(MicSelectorContext); const ref = useRef<HTMLButtonElement>(null);
useEffect(() => { // Track the trigger width so the list below matches it. const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const newWidth = (entry.target as HTMLElement).offsetWidth; if (newWidth) { setWidth?.(newWidth); } } });
if (ref.current) { resizeObserver.observe(ref.current); }
return () => { resizeObserver.disconnect(); }; }, [setWidth]);
return ( <PopoverTrigger data-slot="ai-mic-selector-trigger" ref={ref} render={ <Button variant="outline" className={cn("justify-start font-normal", className)} /> } {...props} > {children} <CaretUpDownIcon className="ml-auto size-3.5 shrink-0 text-muted-foreground" /> </PopoverTrigger> );};
export type MicSelectorContentProps = ComponentProps<typeof Command> & { popoverOptions?: ComponentProps<typeof PopoverContent>;};
export const MicSelectorContent = ({ className, popoverOptions, ...props}: MicSelectorContentProps) => { const { width } = useContext(MicSelectorContext); const { className: popoverClassName, style: popoverStyle, ...popoverRest } = popoverOptions ?? {};
return ( <PopoverContent data-slot="ai-mic-selector-content" align="start" className={cn( "w-auto gap-0 overflow-hidden p-0", popoverClassName as string, )} style={{ minWidth: 320, width, ...(popoverStyle as object) }} {...popoverRest} > <Command className={cn( "rounded-xl **:data-[slot=command-input-wrapper]:h-10", className, )} {...props} /> </PopoverContent> );};
export type MicSelectorInputProps = ComponentProps<typeof CommandInput>;
export const MicSelectorInput = ({ ...props }: MicSelectorInputProps) => ( <CommandInput placeholder="Search microphones..." {...props} />);
export type MicSelectorListProps = Omit< ComponentProps<typeof CommandList>, "children"> & { children: (devices: MediaDeviceInfo[]) => ReactNode;};
export const MicSelectorList = ({ children, ...props}: MicSelectorListProps) => { const { data } = useContext(MicSelectorContext);
return <CommandList {...props}>{children(data)}</CommandList>;};
export type MicSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const MicSelectorEmpty = ({ children = "No microphone found.", ...props}: MicSelectorEmptyProps) => <CommandEmpty {...props}>{children}</CommandEmpty>;
export type MicSelectorItemProps = ComponentProps<typeof CommandItem>;
/** `value` is the device id; the selected item shows the check. */export const MicSelectorItem = ({ value, onSelect, ...props}: MicSelectorItemProps) => { const { value: selected, onValueChange, onOpenChange, } = useContext(MicSelectorContext);
const handleSelect = useCallback( (currentValue: string) => { onValueChange?.(currentValue); onOpenChange?.(false); onSelect?.(currentValue); }, [onValueChange, onOpenChange, onSelect], );
return ( <CommandItem data-checked={value !== undefined && value === selected} onSelect={handleSelect} value={value} {...props} /> );};
export type MicSelectorLabelProps = ComponentProps<"span"> & { device: MediaDeviceInfo; /** Show the hardware id (`1a2b:3c4d`) in muted mono after the name. Default true. */ showId?: boolean;};
export const MicSelectorLabel = ({ device, showId = true, className, ...props}: MicSelectorLabelProps) => { const matches = device.label.match(deviceIdRegex);
if (!matches) { return ( <span className={cn("min-w-0 truncate", className)} {...props}> {device.label} </span> ); }
const [, deviceId] = matches; const name = device.label.replace(deviceIdRegex, "").trim();
return ( <span className={cn("flex min-w-0 flex-1 items-center gap-2", className)} {...props} > <span className="truncate">{name}</span> {showId && ( <span className="ml-auto shrink-0 font-mono text-[11.5px] text-muted-foreground"> {deviceId} </span> )} </span> );};
export type MicSelectorValueProps = ComponentProps<"span">;
export const MicSelectorValue = ({ className, ...props}: MicSelectorValueProps) => { const { data, value } = useContext(MicSelectorContext); const currentDevice = data.find((d) => d.deviceId === value);
if (!currentDevice) { return ( <span className={cn( "flex-1 truncate text-left text-muted-foreground", className, )} {...props} > Select microphone... </span> ); }
return ( <MicSelectorLabel className={cn("flex-1 truncate text-left", className)} device={currentDevice} showId={false} {...props} /> );};<script setup lang="ts">import type { PopoverRootProps } from "reka-ui"import { useVModel } from "@vueuse/core"import { computed, provide, ref, watch } from "vue"import { Popover } from "@/registry/edmi/ui/popover"import { MicSelectorKey } from "./context"import { useAudioDevices } from "./useAudioDevices"
// Built on ui/popover + ui/command (DESIGN §5b). `v-model:value` is the selected device id.const props = withDefaults(defineProps<Omit<PopoverRootProps, "open" | "defaultOpen"> & { value?: string defaultValue?: string open?: boolean defaultOpen?: boolean /** ✦ Devices to list. Omit to enumerate the audio inputs (asks for microphone permission when the list opens). */ devices?: MediaDeviceInfo[]}>(), { value: undefined, open: undefined, defaultOpen: false, devices: undefined,})
const emit = defineEmits<{ (e: "update:value", value: string | undefined): void (e: "update:open", open: boolean): void}>()
const value = useVModel(props, "value", emit, { defaultValue: props.defaultValue, passive: true,})
const open = useVModel(props, "open", emit, { defaultValue: props.defaultOpen, passive: true,})
const width = ref(200)
const { devices: enumerated, hasPermission, loadDevices, loading } = useAudioDevices()const devices = computed(() => props.devices ?? enumerated.value)
watch([open, hasPermission, loading], ([isOpen, permitted, isLoading]) => { if (!props.devices && isOpen && !permitted && !isLoading) loadDevices()})
provide(MicSelectorKey, { devices, value, setValue: (next) => { value.value = next }, open: computed(() => !!open.value), setOpen: (next) => { open.value = next }, width, setWidth: (next) => { width.value = next },})</script>
<template> <Popover :open="!!open" :modal="modal" @update:open="(next) => (open = next)"> <slot /> </Popover></template><script setup lang="ts">import type { PopoverContentProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Command } from "@/registry/edmi/ui/command"import { PopoverContent } from "@/registry/edmi/ui/popover"import { useMicSelector } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] popoverOptions?: PopoverContentProps & { class?: HTMLAttributes["class"] }}>()
const { width } = useMicSelector("MicSelectorContent")</script>
<template> <PopoverContent data-slot="ai-mic-selector-content" align="start" v-bind="props.popoverOptions" :class="cn('w-auto gap-0 overflow-hidden p-0', props.popoverOptions?.class)" :style="{ width: `${width}px`, minWidth: '320px' }" > <Command :class="cn('rounded-xl **:data-[slot=command-input-wrapper]:h-10', props.class)"> <slot /> </Command> </PopoverContent></template><script setup lang="ts">import { CommandEmpty } from "@/registry/edmi/ui/command"</script>
<template> <CommandEmpty> <slot>No microphone found.</slot> </CommandEmpty></template><script setup lang="ts">import { CommandInput } from "@/registry/edmi/ui/command"</script>
<template> <CommandInput placeholder="Search microphones..." /></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { Check } from "@lucide/vue"import { cn } from "@/registry/edmi/lib/utils"import { CommandItem } from "@/registry/edmi/ui/command"import { useMicSelector } from "./context"
const props = defineProps<{ /** The device id. */ value: string class?: HTMLAttributes["class"]}>()
const { value: selected, setValue, setOpen } = useMicSelector("MicSelectorItem")
function handleSelect() { setValue(props.value) setOpen(false)}</script>
<template> <CommandItem :value="props.value" :data-checked="props.value === selected" :class="cn(props.class)" @select="handleSelect" > <slot /> <Check class="ml-auto opacity-0 group-data-[checked=true]/command-item:opacity-100" /> </CommandItem></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = withDefaults(defineProps<{ device: MediaDeviceInfo /** Show the hardware id (`1a2b:3c4d`) in muted mono after the name. */ showId?: boolean class?: HTMLAttributes["class"]}>(), { showId: true })
const deviceIdRegex = /\(([\da-f]{4}:[\da-f]{4})\)$/i
const parsed = computed(() => { const matches = props.device.label.match(deviceIdRegex) if (!matches) return { name: props.device.label, deviceId: null } return { name: props.device.label.replace(deviceIdRegex, "").trim(), deviceId: matches[1] }})</script>
<template> <span :class="cn('flex min-w-0 flex-1 items-center gap-2', props.class)"> <span class="truncate">{{ parsed.name }}</span> <span v-if="parsed.deviceId && showId" class="ml-auto shrink-0 font-mono text-[11.5px] text-muted-foreground" >{{ parsed.deviceId }}</span> </span></template><script setup lang="ts">import { CommandGroup, CommandList } from "@/registry/edmi/ui/command"import { useMicSelector } from "./context"
const { devices } = useMicSelector("MicSelectorList")</script>
<template> <CommandList> <!-- Command items must sit inside a group (see ui/command). --> <CommandGroup> <slot :devices="devices" /> </CommandGroup> </CommandList></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { ChevronsUpDown } from "@lucide/vue"import { useResizeObserver } from "@vueuse/core"import { ref } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Button } from "@/registry/edmi/ui/button"import { PopoverTrigger } from "@/registry/edmi/ui/popover"import { useMicSelector } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { setWidth } = useMicSelector("MicSelectorTrigger")const triggerRef = ref<InstanceType<typeof Button> | null>(null)
// Track the trigger width so the list below matches it.useResizeObserver(triggerRef, (entries) => { const entry = entries[0] if (!entry) return const next = (entry.target as HTMLElement).offsetWidth if (next) setWidth(next)})</script>
<template> <PopoverTrigger as-child> <Button ref="triggerRef" data-slot="ai-mic-selector-trigger" variant="outline" :class="cn('justify-start font-normal', props.class)" > <slot /> <ChevronsUpDown class="ml-auto size-3.5 shrink-0 text-muted-foreground" /> </Button> </PopoverTrigger></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useMicSelector } from "./context"import MicSelectorLabel from "./MicSelectorLabel.vue"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { devices, value } = useMicSelector("MicSelectorValue")const currentDevice = computed(() => devices.value.find((d) => d.deviceId === value.value))</script>
<template> <span v-if="!currentDevice" :class="cn('flex-1 truncate text-left text-muted-foreground', props.class)" > Select microphone... </span> <MicSelectorLabel v-else :class="cn('flex-1 truncate text-left', props.class)" :device="currentDevice" :show-id="false" /></template>import type { InjectionKey, Ref } from "vue";import { inject } from "vue";
export interface MicSelectorContextValue { devices: Ref<MediaDeviceInfo[]>; value: Ref<string | undefined>; setValue: (value: string | undefined) => void; open: Ref<boolean>; setOpen: (open: boolean) => void; width: Ref<number>; setWidth: (width: number) => void;}
export const MicSelectorKey: InjectionKey<MicSelectorContextValue> = Symbol("MicSelector");
export function useMicSelector(componentName: string): MicSelectorContextValue { const context = inject(MicSelectorKey); if (!context) { throw new Error(`${componentName} must be used within MicSelector`); } return context;}import { onMounted, onUnmounted, ref } from "vue";
/** Enumerates the audio inputs; `loadDevices` asks for microphone permission so the labels are filled in. */export function useAudioDevices() { const devices = ref<MediaDeviceInfo[]>([]); const loading = ref(true); const error = ref<string | null>(null); const hasPermission = ref(false);
async function listAudioInputs() { const deviceList = await navigator.mediaDevices.enumerateDevices(); return deviceList.filter((device) => device.kind === "audioinput"); }
async function loadDevicesWithoutPermission() { try { loading.value = true; error.value = null; devices.value = await listAudioInputs(); } catch (err) { const message = err instanceof Error ? err.message : "Failed to get audio devices"; error.value = message; console.error("Error getting audio devices:", message); } finally { loading.value = false; } }
async function loadDevicesWithPermission() { if (loading.value) return; try { loading.value = true; error.value = null; const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true, }); for (const track of tempStream.getTracks()) track.stop(); devices.value = await listAudioInputs(); hasPermission.value = true; } catch (err) { const message = err instanceof Error ? err.message : "Failed to get audio devices"; error.value = message; console.error("Error getting audio devices:", message); } finally { loading.value = false; } }
function handleDeviceChange() { if (hasPermission.value) loadDevicesWithPermission(); else loadDevicesWithoutPermission(); }
onMounted(() => { loadDevicesWithoutPermission(); navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); });
onUnmounted(() => { navigator.mediaDevices.removeEventListener( "devicechange", handleDeviceChange, ); });
return { devices, error, hasPermission, loadDevices: loadDevicesWithPermission, loading, };}export * from "./context";export { default as MicSelector } from "./MicSelector.vue";export { default as MicSelectorContent } from "./MicSelectorContent.vue";export { default as MicSelectorEmpty } from "./MicSelectorEmpty.vue";export { default as MicSelectorInput } from "./MicSelectorInput.vue";export { default as MicSelectorItem } from "./MicSelectorItem.vue";export { default as MicSelectorLabel } from "./MicSelectorLabel.vue";export { default as MicSelectorList } from "./MicSelectorList.vue";export { default as MicSelectorTrigger } from "./MicSelectorTrigger.vue";export { default as MicSelectorValue } from "./MicSelectorValue.vue";export * from "./useAudioDevices";<script lang="ts"> import { Command } from "$lib/registry/ui/command/index.js"; import { PopoverContent } from "$lib/registry/ui/popover/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps, Snippet } from "svelte"; import { useMicSelector } from "./use-mic-selector.svelte.js";
let { class: className, children, popoverOptions, ...restProps }: Omit<ComponentProps<typeof Command>, "children"> & { children?: Snippet; popoverOptions?: Omit<ComponentProps<typeof PopoverContent>, "children">; } = $props();
const ctx = useMicSelector("MicSelectorContent");</script>
<PopoverContent data-slot="ai-mic-selector-content" align="start" {...popoverOptions} class={cn("w-auto gap-0 overflow-hidden p-0", popoverOptions?.class)} style="width: {ctx.width}px; min-width: 320px;"> <Command class={cn("rounded-xl **:data-[slot=command-input-wrapper]:h-10", className)} {...restProps} > {@render children?.()} </Command></PopoverContent><script lang="ts"> import { CommandEmpty } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { children, ...restProps }: ComponentProps<typeof CommandEmpty> = $props();</script>
<CommandEmpty {...restProps}> {#if children} {@render children()} {:else} No microphone found. {/if}</CommandEmpty><script lang="ts"> import { CommandInput } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { value = $bindable(""), ...restProps }: ComponentProps<typeof CommandInput> = $props();</script>
<CommandInput bind:value placeholder="Search microphones..." {...restProps} /><script lang="ts"> import { CommandItem } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte"; import { useMicSelector } from "./use-mic-selector.svelte.js";
// `value` is the device id; the selected item shows the check. let { value, onSelect, children, ...restProps }: ComponentProps<typeof CommandItem> = $props();
const ctx = useMicSelector("MicSelectorItem");</script>
<CommandItem {value} data-checked={value !== undefined && value === ctx.value} onSelect={() => { ctx.setValue(value); ctx.setOpen(false); onSelect?.(); }} {...restProps}> {@render children?.()}</CommandItem><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { device, showId = true, class: className, ...restProps }: HTMLAttributes<HTMLSpanElement> & { device: MediaDeviceInfo; /** Show the hardware id (`1a2b:3c4d`) in muted mono after the name. */ showId?: boolean; } = $props();
const deviceIdRegex = /\(([\da-f]{4}:[\da-f]{4})\)$/i; const parsed = $derived.by(() => { const matches = device.label.match(deviceIdRegex); if (!matches) return { name: device.label, deviceId: null }; return { name: device.label.replace(deviceIdRegex, "").trim(), deviceId: matches[1] }; });</script>
<span class={cn("flex min-w-0 flex-1 items-center gap-2", className)} {...restProps}> <span class="truncate">{parsed.name}</span> {#if parsed.deviceId && showId} <span class="ml-auto shrink-0 font-mono text-[11.5px] text-muted-foreground">{parsed.deviceId}</span> {/if}</span><script lang="ts"> import { CommandList } from "$lib/registry/ui/command/index.js"; import type { ComponentProps, Snippet } from "svelte"; import { useMicSelector } from "./use-mic-selector.svelte.js";
// `children` receives the device list: `{#snippet children(devices)}…{/snippet}`. let { children, ...restProps }: Omit<ComponentProps<typeof CommandList>, "children"> & { children?: Snippet<[MediaDeviceInfo[]]>; } = $props();
const ctx = useMicSelector("MicSelectorList");</script>
<CommandList {...restProps}> {@render children?.(ctx.devices)}</CommandList><script lang="ts"> import CaretUpDownIcon from "phosphor-svelte/lib/CaretUpDown"; import { Button } from "$lib/registry/ui/button/index.js"; import { PopoverTrigger } from "$lib/registry/ui/popover/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps, Snippet } from "svelte"; import { useMicSelector } from "./use-mic-selector.svelte.js";
let { class: className, children, ...restProps }: Omit<ComponentProps<typeof Button>, "children"> & { children?: Snippet } = $props();
const ctx = useMicSelector("MicSelectorTrigger"); let el = $state<HTMLElement | null>(null);
// Track the trigger width so the list below matches it. $effect(() => { if (!el) return; const target = el; const observer = new ResizeObserver(() => { if (target.offsetWidth) ctx.setWidth(target.offsetWidth); }); observer.observe(target); return () => observer.disconnect(); });</script>
<PopoverTrigger data-slot="ai-mic-selector-trigger"> {#snippet child({ props })} <Button bind:ref={el} variant="outline" class={cn("justify-start font-normal", className)} {...props} {...restProps} > {@render children?.()} <CaretUpDownIcon class="ml-auto size-3.5 shrink-0 text-muted-foreground" /> </Button> {/snippet}</PopoverTrigger><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import MicSelectorLabel from "./mic-selector-label.svelte"; import { useMicSelector } from "./use-mic-selector.svelte.js";
let { class: className, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();
const ctx = useMicSelector("MicSelectorValue"); const current = $derived(ctx.devices.find((d) => d.deviceId === ctx.value));</script>
{#if current} <MicSelectorLabel device={current} showId={false} class={cn("flex-1 truncate text-left", className)} {...restProps} />{:else} <span class={cn("flex-1 truncate text-left text-muted-foreground", className)} {...restProps}> Select microphone... </span>{/if}<script lang="ts"> import { Popover } from "$lib/registry/ui/popover/index.js"; import type { ComponentProps } from "svelte"; import { setMicSelectorContext } from "./use-mic-selector.svelte.js"; import { useAudioDevices } from "./use-audio-devices.svelte.js";
// Built on ui/popover + ui/command (DESIGN §5b). `bind:value` is the selected device id. let { value = $bindable(), open = $bindable(false), devices: devicesProp, children, ...restProps }: ComponentProps<typeof Popover> & { value?: string; /** * ✦ Devices to list. Omit to enumerate the audio inputs (asks for microphone permission when the * list opens). */ devices?: MediaDeviceInfo[]; } = $props();
let width = $state(200); const audio = useAudioDevices(); const devices = $derived(devicesProp ?? audio.devices);
$effect(() => { if (!devicesProp && open && !audio.hasPermission && !audio.loading) audio.loadDevices(); });
setMicSelectorContext({ get devices() { return devices; }, get value() { return value; }, setValue: (next) => { value = next; }, setOpen: (next) => { open = next; }, get width() { return width; }, setWidth: (next) => { width = next; }, });</script>
<Popover bind:open {...restProps}> {@render children?.()}</Popover>import { onMount } from "svelte";
/** Enumerates the audio inputs; `loadDevices` asks for microphone permission so the labels are filled in. */export class AudioDevices { devices = $state<MediaDeviceInfo[]>([]); loading = $state(true); error = $state<string | null>(null); hasPermission = $state(false);
async #list() { const deviceList = await navigator.mediaDevices.enumerateDevices(); return deviceList.filter((device) => device.kind === "audioinput"); }
loadWithoutPermission = async () => { try { this.loading = true; this.error = null; this.devices = await this.#list(); } catch (err) { this.error = err instanceof Error ? err.message : "Failed to get audio devices"; console.error("Error getting audio devices:", this.error); } finally { this.loading = false; } };
loadDevices = async () => { if (this.loading) return; try { this.loading = true; this.error = null; const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true, }); for (const track of tempStream.getTracks()) track.stop(); this.devices = await this.#list(); this.hasPermission = true; } catch (err) { this.error = err instanceof Error ? err.message : "Failed to get audio devices"; console.error("Error getting audio devices:", this.error); } finally { this.loading = false; } };}
export function useAudioDevices() { const audio = new AudioDevices();
onMount(() => { audio.loadWithoutPermission(); const onChange = () => { if (audio.hasPermission) { audio.loading = false; audio.loadDevices(); } else audio.loadWithoutPermission(); }; navigator.mediaDevices.addEventListener("devicechange", onChange); return () => navigator.mediaDevices.removeEventListener("devicechange", onChange); });
return audio;}import { getContext, setContext } from "svelte";
/** Getter object so consumers stay reactive when the props change. */export interface MicSelectorContext { readonly devices: MediaDeviceInfo[]; readonly value: string | undefined; setValue: (value: string | undefined) => void; setOpen: (open: boolean) => void; readonly width: number; setWidth: (width: number) => void;}
const KEY = Symbol("ai-mic-selector");
export function setMicSelectorContext(value: MicSelectorContext) { return setContext(KEY, value);}
export function useMicSelector(componentName: string): MicSelectorContext { const context = getContext<MicSelectorContext | undefined>(KEY); if (!context) { throw new Error(`${componentName} must be used within MicSelector`); } return context;}import Root from "./mic-selector.svelte";import Content from "./mic-selector-content.svelte";import Empty from "./mic-selector-empty.svelte";import Input from "./mic-selector-input.svelte";import Item from "./mic-selector-item.svelte";import Label from "./mic-selector-label.svelte";import List from "./mic-selector-list.svelte";import Trigger from "./mic-selector-trigger.svelte";import Value from "./mic-selector-value.svelte";
export { AudioDevices, useAudioDevices,} from "./use-audio-devices.svelte.js";export { Content, Content as MicSelectorContent, Empty, Empty as MicSelectorEmpty, Input, Input as MicSelectorInput, Item, Item as MicSelectorItem, Label, Label as MicSelectorLabel, List, List as MicSelectorList, Root, // Root as MicSelector, Trigger, Trigger as MicSelectorTrigger, Value, Value as MicSelectorValue,};| Part | Prop | Type | Default |
|---|---|---|---|
| MicSelector | value, defaultValue, onValueChange |
device id | |
| MicSelector | open, defaultOpen, onOpenChange |
boolean |
false |
| MicSelector | devices ✦ |
MediaDeviceInfo[]; omit to enumerate the audio inputs (asks for permission when the list opens) |
|
| MicSelectorList | children |
(devices: MediaDeviceInfo[]) => ReactNode |
|
| MicSelectorItem | value |
device id | |
| MicSelectorLabel | device, showId |
MediaDeviceInfo, boolean |
true |
useAudioDevices() is exported for custom pickers.
✦ Edmi additions
Section titled “✦ Edmi additions”devicesprop, so a demo or a server-provided list needs no permission prompt.MicSelectorLabelprints the(1a2b:3c4d)suffix as a muted mono id on the right;MicSelectorValueshows the name only.- The selected item shows the check; selecting closes the popover. The command highlight is no longer tied to the selected value, so arrow keys and hover do not change the selection.
Framework notes
Section titled “Framework notes”- Vue:
v-model:value/v-model:open,MicSelectorListslot receives{ devices }. Svelte:bind:value/bind:open, the listchildrensnippet receivesdevices. Items must live inside a command group in Vue (handled byMicSelectorList).