"use client"; import { useControllableState } from "@radix-ui/react-use-controllable-state"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { ChevronsUpDownIcon } from "lucide-react"; import type { ComponentProps, ReactNode } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; 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({ data: [], onOpenChange: undefined, onValueChange: undefined, open: false, setWidth: undefined, value: undefined, width: 200, }); export const useAudioDevices = () => { const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 & { defaultValue?: string; value?: string | undefined; onValueChange?: (value: string | undefined) => void; open?: boolean; onOpenChange?: (open: boolean) => void; }; export const MicSelector = ({ defaultValue, value: controlledValue, onValueChange: controlledOnValueChange, defaultOpen = false, open: controlledOpen, onOpenChange: controlledOnOpenChange, ...props }: MicSelectorProps) => { const [value, onValueChange] = useControllableState({ defaultProp: defaultValue, onChange: controlledOnValueChange, prop: controlledValue, }); const [open, onOpenChange] = useControllableState({ defaultProp: defaultOpen, onChange: controlledOnOpenChange, prop: controlledOpen, }); const [width, setWidth] = useState(200); const { devices, loading, hasPermission, loadDevices } = useAudioDevices(); useEffect(() => { if (open && !hasPermission && !loading) { loadDevices(); } }, [open, hasPermission, loading, loadDevices]); const contextValue = useMemo( () => ({ data: devices, onOpenChange, onValueChange, open, setWidth, value, width, }), [devices, onOpenChange, onValueChange, open, setWidth, value, width] ); return ( ); }; export type MicSelectorTriggerProps = ComponentProps; export const MicSelectorTrigger = ({ children, ...props }: MicSelectorTriggerProps) => { const { setWidth } = useContext(MicSelectorContext); const ref = useRef(null); useEffect(() => { // Create a ResizeObserver to detect width changes 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); } // Clean up the observer when component unmounts return () => { resizeObserver.disconnect(); }; }, [setWidth]); return ( ); }; export type MicSelectorContentProps = ComponentProps & { popoverOptions?: ComponentProps; }; export const MicSelectorContent = ({ className, popoverOptions, ...props }: MicSelectorContentProps) => { const { width, onValueChange, value } = useContext(MicSelectorContext); return ( ); }; export type MicSelectorInputProps = ComponentProps & { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; }; export const MicSelectorInput = ({ ...props }: MicSelectorInputProps) => ( ); export type MicSelectorListProps = Omit< ComponentProps, "children" > & { children: (devices: MediaDeviceInfo[]) => ReactNode; }; export const MicSelectorList = ({ children, ...props }: MicSelectorListProps) => { const { data } = useContext(MicSelectorContext); return {children(data)}; }; export type MicSelectorEmptyProps = ComponentProps; export const MicSelectorEmpty = ({ children = "No microphone found.", ...props }: MicSelectorEmptyProps) => {children}; export type MicSelectorItemProps = ComponentProps; export const MicSelectorItem = (props: MicSelectorItemProps) => { const { onValueChange, onOpenChange } = useContext(MicSelectorContext); const handleSelect = useCallback( (currentValue: string) => { onValueChange?.(currentValue); onOpenChange?.(false); }, [onValueChange, onOpenChange] ); return ; }; export type MicSelectorLabelProps = ComponentProps<"span"> & { device: MediaDeviceInfo; }; export const MicSelectorLabel = ({ device, className, ...props }: MicSelectorLabelProps) => { const matches = device.label.match(deviceIdRegex); if (!matches) { return ( {device.label} ); } const [, deviceId] = matches; const name = device.label.replace(deviceIdRegex, ""); return ( {name} ({deviceId}) ); }; 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 ( Select microphone... ); } return ( ); };