{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "mic-selector",
	"title": "Mic Selector",
	"type": "registry:ui",
	"description": "A microphone device picker with live level preview and permission handling.",
	"dependencies": [
		"@lucide/svelte"
	],
	"devDependencies": [
		"@lucide/svelte@^1.7.0"
	],
	"registryDependencies": [
		"button",
		"https://sv11.ui.twango.dev/r/live-waveform.json"
	],
	"files": [
		{
			"content": "<script lang=\"ts\" module>\n\texport type MicSelectorProps = {\n\t\t/**\n\t\t * Selected microphone `deviceId`. Leave unset for uncontrolled mode —\n\t\t * the first available device is auto-selected.\n\t\t */\n\t\tvalue?: string;\n\t\t/** Called when the user picks a different microphone from the dropdown. */\n\t\tonValueChange?: (deviceId: string) => void;\n\t\t/**\n\t\t * Mute state. Leave unset for uncontrolled mode, where the component\n\t\t * tracks mute internally.\n\t\t */\n\t\tmuted?: boolean;\n\t\t/** Called when the user toggles the mute button. */\n\t\tonMutedChange?: (muted: boolean) => void;\n\t\t/** Disables the trigger button. */\n\t\tdisabled?: boolean;\n\t\tclass?: string;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport Check from \"@lucide/svelte/icons/check\";\n\timport ChevronsUpDown from \"@lucide/svelte/icons/chevrons-up-down\";\n\timport Mic from \"@lucide/svelte/icons/mic\";\n\timport MicOff from \"@lucide/svelte/icons/mic-off\";\n\timport { Button } from \"$UI$/button/index.js\";\n\timport {\n\t\tDropdownMenu,\n\t\tDropdownMenuContent,\n\t\tDropdownMenuItem,\n\t\tDropdownMenuSeparator,\n\t\tDropdownMenuTrigger,\n\t} from \"$UI$/dropdown-menu/index.js\";\n\timport { LiveWaveform } from \"$UI$/live-waveform/index.js\";\n\timport { cn } from \"$UTILS$.js\";\n\timport { useAudioDevices } from \"./use-audio-devices.svelte.js\";\n\n\tlet {\n\t\tvalue,\n\t\tonValueChange,\n\t\tmuted,\n\t\tonMutedChange,\n\t\tdisabled,\n\t\tclass: className,\n\t}: MicSelectorProps = $props();\n\n\tconst audio = useAudioDevices();\n\n\tlet selectedDevice = $state(\"\");\n\tlet internalMuted = $state(false);\n\tlet isDropdownOpen = $state(false);\n\n\tconst isMuted = $derived(muted !== undefined ? muted : internalMuted);\n\tconst defaultDeviceId = $derived(audio.devices[0]?.deviceId ?? \"\");\n\tconst currentDevice = $derived(\n\t\taudio.devices.find((d) => d.deviceId === selectedDevice) ??\n\t\t\taudio.devices[0] ?? {\n\t\t\t\tlabel: audio.loading ? \"Loading...\" : \"No microphone\",\n\t\t\t\tdeviceId: \"\",\n\t\t\t\tgroupId: \"\",\n\t\t\t}\n\t);\n\tconst isPreviewActive = $derived(isDropdownOpen && !isMuted);\n\n\t$effect(() => {\n\t\tif (value !== undefined) selectedDevice = value;\n\t});\n\n\t$effect(() => {\n\t\tif (!selectedDevice && defaultDeviceId) {\n\t\t\tselectedDevice = defaultDeviceId;\n\t\t\tonValueChange?.(defaultDeviceId);\n\t\t}\n\t});\n\n\tfunction handleDeviceSelect(deviceId: string) {\n\t\tselectedDevice = deviceId;\n\t\tonValueChange?.(deviceId);\n\t}\n\n\tasync function handleDropdownOpenChange(open: boolean) {\n\t\tisDropdownOpen = open;\n\t\tif (open && !audio.hasPermission && !audio.loading) {\n\t\t\tawait audio.loadDevices();\n\t\t}\n\t}\n\n\tfunction toggleMute() {\n\t\tconst newMuted = !isMuted;\n\t\tif (muted === undefined) internalMuted = newMuted;\n\t\tonMutedChange?.(newMuted);\n\t}\n</script>\n\n<DropdownMenu onOpenChange={handleDropdownOpenChange}>\n\t<DropdownMenuTrigger>\n\t\t{#snippet child({ props })}\n\t\t\t<Button\n\t\t\t\tvariant=\"ghost\"\n\t\t\t\tsize=\"sm\"\n\t\t\t\t{...props}\n\t\t\t\tclass={cn(\n\t\t\t\t\t\"hover:bg-accent flex w-40 min-w-0 shrink cursor-pointer items-center gap-1.5 sm:w-48\",\n\t\t\t\t\tclassName\n\t\t\t\t)}\n\t\t\t\tdisabled={audio.loading || disabled}\n\t\t\t>\n\t\t\t\t{#if isMuted}\n\t\t\t\t\t<MicOff class=\"h-4 w-4 flex-shrink-0\" />\n\t\t\t\t{:else}\n\t\t\t\t\t<Mic class=\"h-4 w-4 flex-shrink-0\" />\n\t\t\t\t{/if}\n\t\t\t\t<span class=\"min-w-0 flex-1 truncate text-left text-xs sm:text-sm\">\n\t\t\t\t\t{currentDevice.label}\n\t\t\t\t</span>\n\t\t\t\t<ChevronsUpDown class=\"h-3 w-3 flex-shrink-0\" />\n\t\t\t</Button>\n\t\t{/snippet}\n\t</DropdownMenuTrigger>\n\t<DropdownMenuContent align=\"center\" side=\"top\" class=\"w-72\">\n\t\t{#if audio.loading}\n\t\t\t<DropdownMenuItem disabled>Loading devices...</DropdownMenuItem>\n\t\t{:else if audio.error}\n\t\t\t<DropdownMenuItem disabled>Error: {audio.error}</DropdownMenuItem>\n\t\t{:else}\n\t\t\t{#each audio.devices as device (device.deviceId)}\n\t\t\t\t<DropdownMenuItem\n\t\t\t\t\tonSelect={(e) => {\n\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\thandleDeviceSelect(device.deviceId);\n\t\t\t\t\t}}\n\t\t\t\t\tclass=\"flex items-center justify-between\"\n\t\t\t\t>\n\t\t\t\t\t<span class=\"truncate\">{device.label}</span>\n\t\t\t\t\t{#if selectedDevice === device.deviceId}\n\t\t\t\t\t\t<Check class=\"h-4 w-4 flex-shrink-0\" />\n\t\t\t\t\t{/if}\n\t\t\t\t</DropdownMenuItem>\n\t\t\t{/each}\n\t\t{/if}\n\n\t\t{#if audio.devices.length > 0}\n\t\t\t<DropdownMenuSeparator />\n\t\t\t<div class=\"flex items-center gap-2 p-2\">\n\t\t\t\t<Button\n\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\tonclick={(e) => {\n\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\ttoggleMute();\n\t\t\t\t\t}}\n\t\t\t\t\tclass=\"h-8 gap-2\"\n\t\t\t\t>\n\t\t\t\t\t{#if isMuted}\n\t\t\t\t\t\t<MicOff class=\"h-4 w-4\" />\n\t\t\t\t\t{:else}\n\t\t\t\t\t\t<Mic class=\"h-4 w-4\" />\n\t\t\t\t\t{/if}\n\t\t\t\t\t<span class=\"text-sm\">{isMuted ? \"Unmute\" : \"Mute\"}</span>\n\t\t\t\t</Button>\n\t\t\t\t<div class=\"bg-accent ml-auto w-16 overflow-hidden rounded-md p-1.5\">\n\t\t\t\t\t<LiveWaveform\n\t\t\t\t\t\tactive={isPreviewActive}\n\t\t\t\t\t\tdeviceId={selectedDevice || defaultDeviceId}\n\t\t\t\t\t\tmode=\"static\"\n\t\t\t\t\t\theight={15}\n\t\t\t\t\t\tbarWidth={3}\n\t\t\t\t\t\tbarGap={1}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t{/if}\n\t</DropdownMenuContent>\n</DropdownMenu>\n",
			"type": "registry:ui",
			"target": "mic-selector/mic-selector.svelte"
		},
		{
			"content": "export type AudioDevice = {\n\tdeviceId: string;\n\tlabel: string;\n\tgroupId: string;\n};\n\nfunction mapAudioInputs(deviceList: MediaDeviceInfo[]): AudioDevice[] {\n\treturn deviceList\n\t\t.filter((device) => device.kind === \"audioinput\")\n\t\t.map((device) => {\n\t\t\tlet cleanLabel = device.label || `Microphone ${device.deviceId.slice(0, 8)}`;\n\t\t\tcleanLabel = cleanLabel.replace(/\\s*\\([^)]*\\)/g, \"\").trim();\n\t\t\treturn {\n\t\t\t\tdeviceId: device.deviceId,\n\t\t\t\tlabel: cleanLabel,\n\t\t\t\tgroupId: device.groupId,\n\t\t\t};\n\t\t});\n}\n\n/**\n * Enumerates available audio input devices and re-enumerates on\n * `devicechange` events. Must be called from a component's script\n * setup phase — effects attach to the calling component's scope.\n */\nexport function useAudioDevices() {\n\tlet devices = $state<AudioDevice[]>([]);\n\tlet loading = $state(true);\n\tlet error = $state<string | null>(null);\n\tlet hasPermission = $state(false);\n\n\tasync function loadDevicesWithoutPermission() {\n\t\ttry {\n\t\t\tloading = true;\n\t\t\terror = null;\n\t\t\tconst deviceList = await navigator.mediaDevices.enumerateDevices();\n\t\t\tdevices = mapAudioInputs(deviceList);\n\t\t} catch (err) {\n\t\t\terror = err instanceof Error ? err.message : \"Failed to get audio devices\";\n\t\t\tconsole.error(\"Error getting audio devices:\", err);\n\t\t} finally {\n\t\t\tloading = false;\n\t\t}\n\t}\n\n\tasync function loadDevicesWithPermission() {\n\t\tif (loading) return;\n\t\ttry {\n\t\t\tloading = true;\n\t\t\terror = null;\n\t\t\tconst tempStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n\t\t\ttempStream.getTracks().forEach((track) => track.stop());\n\t\t\tconst deviceList = await navigator.mediaDevices.enumerateDevices();\n\t\t\tdevices = mapAudioInputs(deviceList);\n\t\t\thasPermission = true;\n\t\t} catch (err) {\n\t\t\terror = err instanceof Error ? err.message : \"Failed to get audio devices\";\n\t\t\tconsole.error(\"Error getting audio devices:\", err);\n\t\t} finally {\n\t\t\tloading = false;\n\t\t}\n\t}\n\n\t$effect(() => {\n\t\tloadDevicesWithoutPermission();\n\t});\n\n\t$effect(() => {\n\t\tconst handleDeviceChange = () => {\n\t\t\tif (hasPermission) {\n\t\t\t\tloadDevicesWithPermission();\n\t\t\t} else {\n\t\t\t\tloadDevicesWithoutPermission();\n\t\t\t}\n\t\t};\n\t\tnavigator.mediaDevices.addEventListener(\"devicechange\", handleDeviceChange);\n\t\treturn () => {\n\t\t\tnavigator.mediaDevices.removeEventListener(\"devicechange\", handleDeviceChange);\n\t\t};\n\t});\n\n\treturn {\n\t\tget devices() {\n\t\t\treturn devices;\n\t\t},\n\t\tget loading() {\n\t\t\treturn loading;\n\t\t},\n\t\tget error() {\n\t\t\treturn error;\n\t\t},\n\t\tget hasPermission() {\n\t\t\treturn hasPermission;\n\t\t},\n\t\tloadDevices: loadDevicesWithPermission,\n\t};\n}\n",
			"type": "registry:ui",
			"target": "mic-selector/use-audio-devices.svelte.ts"
		},
		{
			"content": "import Root from \"./mic-selector.svelte\";\n\nexport {\n\tRoot,\n\t//\n\tRoot as MicSelector,\n};\nexport { useAudioDevices } from \"./use-audio-devices.svelte.js\";\nexport type { AudioDevice } from \"./use-audio-devices.svelte.js\";\nexport type { MicSelectorProps } from \"./mic-selector.svelte\";\n",
			"type": "registry:ui",
			"target": "mic-selector/index.ts"
		}
	]
}