Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/docs/content/components/(voice)/mic-selector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ The component implements a two-stage permission approach:
1. **Without Permission**: Initially loads devices without requesting permission. Device labels may show as generic names (e.g., "Microphone 1").
2. **With Permission**: When the popover is opened and permission hasn't been granted, automatically requests microphone access and displays actual device names.

At most one automatic permission request is made per popover open. If the user denies permission, the error is surfaced through `useAudioDevices` and no further requests are made until the popover is reopened or `loadDevices` is called explicitly.

### Device Label Parsing

The `MicSelectorLabel` component intelligently parses device names that include hardware IDs in the format `(XXXX:XXXX)`. It splits the label into the device name and ID, styling the ID with muted text for better readability.
Expand Down
65 changes: 65 additions & 0 deletions packages/elements/__tests__/mic-selector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,71 @@ describe("micSelector", () => {
expect(onValueChange).toHaveBeenCalledWith("device-2");
});
});

it("keeps getUserMedia bounded after permission denial", async () => {
setupMocks();
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(vi.fn());
mockGetUserMedia.mockClear();
mockGetUserMedia
.mockRejectedValueOnce(new Error("Permission denied"))
.mockRejectedValueOnce(new Error("Permission denied"));

const user = userEvent.setup();

render(
<MicSelector>
<MicSelectorTrigger>
<MicSelectorValue />
</MicSelectorTrigger>
<MicSelectorContent>
<MicSelectorInput />
<MicSelectorList>
{(devices) =>
devices.map((device) => (
<MicSelectorItem key={device.deviceId} value={device.deviceId}>
{device.label}
</MicSelectorItem>
))
}
</MicSelectorList>
<MicSelectorEmpty />
</MicSelectorContent>
</MicSelector>
);

await user.click(screen.getByRole("button"));

await waitFor(() => {
expect(mockGetUserMedia).toHaveBeenCalledOnce();
});

// Wait beyond the first rejection: further render turns must not
// trigger another automatic permission request.
const firstFlush = Promise.withResolvers<void>();
setTimeout(firstFlush.resolve, 100);
await firstFlush.promise;

expect(mockGetUserMedia).toHaveBeenCalledOnce();

// Reopening the popover is the defined retry boundary: exactly one
// more automatic request.
await user.keyboard("{Escape}");
await user.click(screen.getByRole("button"));

await waitFor(() => {
expect(mockGetUserMedia).toHaveBeenCalledTimes(2);
});

const secondFlush = Promise.withResolvers<void>();
setTimeout(secondFlush.resolve, 100);
await secondFlush.promise;

expect(mockGetUserMedia).toHaveBeenCalledTimes(2);

consoleErrorSpy.mockRestore();
});
});

describe("micSelectorTrigger", () => {
Expand Down
31 changes: 21 additions & 10 deletions packages/elements/src/mic-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client";

import type { ComponentProps, ReactNode } from "react";

import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Button } from "@repo/shadcn-ui/components/ui/button";
import {
Expand All @@ -16,7 +18,6 @@ import {
} from "@repo/shadcn-ui/components/ui/popover";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ChevronsUpDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
useCallback,
Expand Down Expand Up @@ -54,9 +55,11 @@ export const useAudioDevices = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasPermission, setHasPermission] = useState(false);
const loadingRef = useRef(true);

const loadDevicesWithoutPermission = useCallback(async () => {
try {
loadingRef.current = true;
setLoading(true);
setError(null);

Expand All @@ -75,16 +78,18 @@ export const useAudioDevices = () => {
setError(message);
console.error("Error getting audio devices:", message);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, []);

const loadDevicesWithPermission = useCallback(async () => {
if (loading) {
if (loadingRef.current) {
return;
}

try {
loadingRef.current = true;
setLoading(true);
setError(null);

Expand Down Expand Up @@ -112,9 +117,10 @@ export const useAudioDevices = () => {
setError(message);
console.error("Error getting audio devices:", message);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [loading]);
}, []);

useEffect(() => {
loadDevicesWithoutPermission();
Expand Down Expand Up @@ -176,13 +182,18 @@ export const MicSelector = ({
prop: controlledOpen,
});
const [width, setWidth] = useState(200);
const { devices, loading, hasPermission, loadDevices } = useAudioDevices();
const { devices, hasPermission, loadDevices } = useAudioDevices();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Controlled programmatic open={true} no longer triggers microphone permission/device enumeration because loading was moved into Radix's onOpenChange, which isn't fired for controlled prop changes.

Fix on Vercel


useEffect(() => {
if (open && !hasPermission && !loading) {
loadDevices();
}
}, [open, hasPermission, loading, loadDevices]);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
onOpenChange(nextOpen);

if (nextOpen && !hasPermission) {
loadDevices();
}
},
[onOpenChange, hasPermission, loadDevices]
);

const contextValue = useMemo(
() => ({
Expand All @@ -199,7 +210,7 @@ export const MicSelector = ({

return (
<MicSelectorContext.Provider value={contextValue}>
<Popover {...props} onOpenChange={onOpenChange} open={open} />
<Popover {...props} onOpenChange={handleOpenChange} open={open} />
</MicSelectorContext.Provider>
);
};
Expand Down