Skip to content
Closed
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

179 changes: 176 additions & 3 deletions apps/desktop/src/components/editor-area/note-header/listen-button.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { Trans } from "@lingui/react/macro";
import { useMutation, useQuery } from "@tanstack/react-query";
import { MicIcon, MicOffIcon, PauseIcon, PlayIcon, StopCircleIcon, Volume2Icon, VolumeOffIcon } from "lucide-react";
import { Trans, useLingui } from "@lingui/react/macro";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckIcon,
ChevronDownIcon,
MicIcon,
MicOffIcon,
PauseIcon,
PlayIcon,
StopCircleIcon,
Volume2Icon,
VolumeOffIcon,
} from "lucide-react";
import { useEffect, useState } from "react";

import SoundIndicator from "@/components/sound-indicator";
import { useHypr } from "@/contexts";
import { useEnhancePendingState } from "@/hooks/enhance-pending";
import { MicrophoneDeviceInfo } from "@/utils/microphone-devices";
import { commands as dbCommands } from "@hypr/plugin-db";
import { commands as listenerCommands } from "@hypr/plugin-listener";
import { commands as localSttCommands } from "@hypr/plugin-local-stt";
Expand Down Expand Up @@ -390,6 +401,10 @@ function AudioControlButton({
? VolumeOffIcon
: Volume2Icon;

if (type === "mic") {
return <MicControlWithDropdown isMuted={isMuted} onMuteClick={onClick} disabled={disabled} />;
}

return (
<Button
variant="ghost"
Expand All @@ -403,3 +418,161 @@ function AudioControlButton({
</Button>
);
}

function MicControlWithDropdown({
isMuted,
onMuteClick,
disabled,
}: {
isMuted?: boolean;
onMuteClick: () => void;
disabled?: boolean;
}) {
const { t } = useLingui();
const queryClient = useQueryClient();

const Icon = isMuted ? MicOffIcon : MicIcon;

const micPermissionStatus = useQuery({
queryKey: ["micPermission"],
queryFn: () => listenerCommands.checkMicrophoneAccess(),
});

const deviceQuery = useQuery<MicrophoneDeviceInfo>({
queryKey: ["microphoneDeviceInfo"],
queryFn: async () => {
const result = await listenerCommands.getSelectedMicrophoneDevice();
return result;
},
enabled: micPermissionStatus.data === true,
});

const updateSelectedDevice = useMutation({
mutationFn: (deviceName: string | null) => listenerCommands.setSelectedMicrophoneDevice(deviceName),
onSuccess: (_, deviceName) => {
const displayName = deviceName === null ? t`System Default` : deviceName;

sonnerToast.success(t`Microphone switched to ${displayName}`, {
duration: 2000,
});

// Invalidate all microphone-related queries
queryClient.invalidateQueries({ queryKey: ["microphoneDeviceInfo"] });
queryClient.invalidateQueries({ queryKey: ["microphoneDevices"] });
queryClient.invalidateQueries({ queryKey: ["selectedMicrophoneDevice"] });
},
onError: (error, deviceName) => {
const displayName = deviceName === null ? t`System Default` : deviceName;

sonnerToast.error(t`Failed to switch to ${displayName}`, {
description: t`Please try again or check your microphone permissions.`,
duration: 4000,
});

// Refresh state even on error
queryClient.invalidateQueries({ queryKey: ["microphoneDeviceInfo"] });
queryClient.invalidateQueries({ queryKey: ["microphoneDevices"] });
queryClient.invalidateQueries({ queryKey: ["selectedMicrophoneDevice"] });
},
});

const handleMicrophoneDeviceChange = (deviceName: string) => {
const deviceToSet = deviceName === "default" ? null : deviceName;
updateSelectedDevice.mutate(deviceToSet);
};

const getSelectedDevice = () => {
const currentDevice = deviceQuery.data?.selected;
if (!currentDevice) {
return "default";
}
if (deviceQuery.data?.devices && !deviceQuery.data.devices.includes(currentDevice)) {
return "default";
}
return currentDevice;
};

const getDisplayName = (deviceName: string) => {
if (deviceName === "default") {
return t`System Default`;
}
return deviceName;
};

return (
<div className="flex w-full">
<Button
variant="ghost"
size="icon"
onClick={onMuteClick}
className="flex-1"
disabled={disabled}
>
<Icon className={cn(isMuted ? "text-neutral-500" : "", disabled && "text-neutral-300")} size={20} />
{!disabled && <SoundIndicator input="mic" size="long" />}
</Button>

{micPermissionStatus.data && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-6 px-1"
disabled={deviceQuery.isLoading || updateSelectedDevice.isPending}
>
<ChevronDownIcon size={12} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56" align="start">
<div className="space-y-1">
<div className="px-2 py-1.5 text-sm font-medium text-muted-foreground">
<Trans>Microphone</Trans>
</div>

{deviceQuery.isLoading
? (
<div className="flex items-center gap-2 px-2 py-1.5 text-sm">
<Spinner size={14} />
<Trans>Loading devices...</Trans>
</div>
)
: deviceQuery.error
? (
<div className="px-2 py-1.5 text-sm text-red-600">
<Trans>Failed to load microphone devices. Please check permissions.</Trans>
</div>
)
: (
<>
<Button
variant="ghost"
size="sm"
className="w-full justify-between h-8"
onClick={() => handleMicrophoneDeviceChange("default")}
>
<span className="truncate">{getDisplayName("default")}</span>
{getSelectedDevice() === "default" && <CheckIcon size={16} className="text-green-600" />}
</Button>

{deviceQuery.data?.devices?.map((device) => (
<Button
key={device}
variant="ghost"
size="sm"
className="w-full justify-between h-8"
onClick={() => handleMicrophoneDeviceChange(device)}
>
<span className="truncate">{device}</span>
{getSelectedDevice() === device && <CheckIcon size={16} className="text-green-600" />}
</Button>
))}
</>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}
3 changes: 2 additions & 1 deletion apps/desktop/src/components/settings/views/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ export default function General() {
telemetry_consent: v.telemetryConsent ?? true,
jargons: v.jargons.split(",").map((jargon) => jargon.trim()).filter(Boolean),
save_recordings: v.saveRecordings ?? true,
selected_template_id: config.data.general.selected_template_id,
selected_microphone_device: config.data?.general?.selected_microphone_device ?? null,
selected_template_id: config.data?.general?.selected_template_id ?? null,
};

await dbCommands.setConfig({
Expand Down
123 changes: 102 additions & 21 deletions apps/desktop/src/components/settings/views/sound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { MicIcon, Volume2Icon } from "lucide-react";

import { commands as listenerCommands } from "@hypr/plugin-listener";
import { Button } from "@hypr/ui/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hypr/ui/components/ui/select";
import { Spinner } from "@hypr/ui/components/ui/spinner";
import { cn } from "@hypr/ui/lib/utils";
import { MicrophoneDeviceInfo } from "../../../utils/microphone-devices";

interface PermissionItemProps {
icon: React.ReactNode;
Expand Down Expand Up @@ -77,38 +79,117 @@ export default function Sound() {
queryFn: () => listenerCommands.checkSystemAudioAccess(),
});

const deviceQuery = useQuery<MicrophoneDeviceInfo>({
queryKey: ["microphoneDeviceInfo"],
queryFn: () => listenerCommands.getSelectedMicrophoneDevice(),
enabled: micPermissionStatus.data === true,
});

const micPermission = useMutation({
mutationFn: () => listenerCommands.requestMicrophoneAccess(),
onSuccess: () => micPermissionStatus.refetch(),
onError: console.error,
onSuccess: () => {
micPermissionStatus.refetch();
deviceQuery.refetch();
},
});

const capturePermission = useMutation({
mutationFn: () => listenerCommands.requestSystemAudioAccess(),
onSuccess: () => systemAudioPermissionStatus.refetch(),
onError: console.error,
});

const updateSelectedDevice = useMutation({
mutationFn: (deviceName: string | null) => listenerCommands.setSelectedMicrophoneDevice(deviceName),
onSuccess: () => deviceQuery.refetch(),
});

const handleMicrophoneDeviceChange = (deviceName: string) => {
const deviceToSet = deviceName === "default" ? null : deviceName;
updateSelectedDevice.mutate(deviceToSet);
};

const getSelectedDevice = () => {
const currentDevice = deviceQuery.data?.selected;
if (!currentDevice) {
return "default";
}

// Check if the selected device is still available
if (deviceQuery.data?.devices && !deviceQuery.data.devices.includes(currentDevice)) {
return "default";
}

return currentDevice;
};

return (
<div>
<div className="space-y-2">
<PermissionItem
icon={<MicIcon className="h-4 w-4" />}
title={t`Microphone Access`}
description={t`Required to transcribe your voice during meetings`}
done={micPermissionStatus.data}
isPending={micPermission.isPending}
onRequest={() => micPermission.mutate({})}
/>

<PermissionItem
icon={<Volume2Icon className="h-4 w-4" />}
title={t`System Audio Access`}
description={t`Required to transcribe other people's voice during meetings`}
done={systemAudioPermissionStatus.data}
isPending={capturePermission.isPending}
onRequest={() => capturePermission.mutate({})}
/>
<div className="space-y-4">
<div className="space-y-2">
<PermissionItem
icon={<MicIcon className="h-4 w-4" />}
title={t`Microphone Access`}
description={t`Required to transcribe your voice during meetings`}
done={micPermissionStatus.data}
isPending={micPermission.isPending}
onRequest={() => micPermission.mutate()}
/>

<PermissionItem
icon={<Volume2Icon className="h-4 w-4" />}
title={t`System Audio Access`}
description={t`Required to transcribe other people's voice during meetings`}
done={systemAudioPermissionStatus.data}
isPending={capturePermission.isPending}
onRequest={() => capturePermission.mutate()}
/>
</div>

{micPermissionStatus.data && (
<div className="rounded-lg border p-4">
<div className="flex items-center gap-3 mb-3">
<div className="flex size-6 items-center justify-center">
<MicIcon className="h-4 w-4" />
</div>
<div>
<div className="text-sm font-medium">
<Trans>Microphone Device</Trans>
</div>
<div className="text-xs text-muted-foreground">
<Trans>Select which microphone to use for recording</Trans>
</div>
</div>
</div>

<div className="ml-9">
<Select
value={getSelectedDevice()}
onValueChange={handleMicrophoneDeviceChange}
disabled={deviceQuery.isLoading || updateSelectedDevice.isPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t`Select microphone device`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
<Trans>System Default</Trans>
</SelectItem>
{deviceQuery.data?.devices?.map((device) => (
<SelectItem key={device} value={device}>
{device}
</SelectItem>
))}
</SelectContent>
</Select>

{deviceQuery.isLoading && (
<div className="text-xs text-muted-foreground mt-2">
<Trans>Loading available devices...</Trans>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
Expand Down
Loading
Loading