diff options
Diffstat (limited to 'apps/web/src/voiceover/voiceover-popover.tsx')
| -rw-r--r-- | apps/web/src/voiceover/voiceover-popover.tsx | 276 |
1 files changed, 276 insertions, 0 deletions
diff --git a/apps/web/src/voiceover/voiceover-popover.tsx b/apps/web/src/voiceover/voiceover-popover.tsx new file mode 100644 index 00000000..a1f74646 --- /dev/null +++ b/apps/web/src/voiceover/voiceover-popover.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { MicIcon, StopIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { invokeAction } from "@/actions"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/utils/ui"; +import { + DEFAULT_AUDIO_INPUT_DEVICE_ID, + listVoiceOverInputDevices, + normalizeSelectedDeviceId, + type VoiceOverInputDevice, +} from "@/voiceover/devices"; +import { + VOICE_OVER_ENHANCEMENT_LABELS, + type VoiceOverEnhancementSettings, +} from "@/voiceover/enhancements"; +import { VoiceOverLiveWaveform } from "@/voiceover/live-waveform"; +import { + formatElapsedSeconds, + getVoiceOverErrorMessage, +} from "@/voiceover/recorder"; +import { useRecordingElapsedSeconds } from "@/voiceover/use-recording-elapsed"; +import { + useVoiceOverPreferencesStore, + useVoiceOverStore, +} from "@/voiceover/voiceover-store"; + +const ENHANCEMENT_ORDER: Array<keyof VoiceOverEnhancementSettings> = [ + "voiceBoost", + "noiseGate", + "highPass", + "deEss", + "limiter", + "normalize", +]; + +export function VoiceOverRecorderPopover() { + const [open, setOpen] = useState(false); + const [devices, setDevices] = useState<VoiceOverInputDevice[]>([]); + const [isLoadingDevices, setIsLoadingDevices] = useState(false); + const status = useVoiceOverStore((state) => state.status); + const isRecording = status === "recording"; + const isProcessing = status === "processing"; + const elapsedSeconds = useRecordingElapsedSeconds(); + const selectedDeviceId = useVoiceOverPreferencesStore( + (state) => state.selectedDeviceId, + ); + const enhancements = useVoiceOverPreferencesStore( + (state) => state.enhancements, + ); + const setSelectedDeviceId = useVoiceOverPreferencesStore( + (state) => state.setSelectedDeviceId, + ); + const setEnhancementEnabled = useVoiceOverPreferencesStore( + (state) => state.setEnhancementEnabled, + ); + + const devicesWithDefault = useMemo(() => { + if ( + devices.some( + (device) => device.deviceId === DEFAULT_AUDIO_INPUT_DEVICE_ID, + ) + ) { + return devices; + } + return [ + { + deviceId: DEFAULT_AUDIO_INPUT_DEVICE_ID, + label: "System default microphone", + isDefault: true, + }, + ...devices, + ]; + }, [devices]); + + const selectedValue = selectedDeviceId ?? DEFAULT_AUDIO_INPUT_DEVICE_ID; + + const refreshDevices = useCallback( + async ({ requestPermission }: { requestPermission: boolean }) => { + setIsLoadingDevices(true); + try { + const nextDevices = await listVoiceOverInputDevices({ + requestPermission, + }); + setDevices(nextDevices); + const normalizedDeviceId = normalizeSelectedDeviceId({ + deviceId: selectedDeviceId, + devices: nextDevices, + }); + if (normalizedDeviceId !== selectedDeviceId) { + setSelectedDeviceId({ deviceId: normalizedDeviceId }); + } + } catch (error) { + toast.error(getVoiceOverErrorMessage({ error })); + } finally { + setIsLoadingDevices(false); + } + }, + [selectedDeviceId, setSelectedDeviceId], + ); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (nextOpen) { + void refreshDevices({ requestPermission: true }); + } + }, + [refreshDevices], + ); + + const handleRecordClick = (event: React.MouseEvent) => { + event.stopPropagation(); + void invokeAction("voice-over"); + }; + + return ( + <Popover open={open} onOpenChange={handleOpenChange}> + <TooltipProvider delayDuration={500}> + <Tooltip delayDuration={200}> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <Button + variant={isRecording ? "secondary" : "text"} + size="icon" + disabled={isProcessing} + className="rounded-sm" + onClick={(event) => event.stopPropagation()} + > + <HugeiconsIcon icon={isRecording ? StopIcon : MicIcon} /> + </Button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent> + {isProcessing + ? "Saving voice-over" + : isRecording + ? "Stop voice-over" + : "Record voice-over"} + </TooltipContent> + </Tooltip> + </TooltipProvider> + + <PopoverContent + align="start" + className="w-80 p-3" + onClick={(event) => event.stopPropagation()} + > + <div className="space-y-3"> + <div className="flex items-center justify-between gap-3"> + <div className="min-w-0"> + <div className="text-sm font-medium">Voice-over</div> + <div className="text-muted-foreground text-xs tabular-nums"> + {isRecording + ? formatElapsedSeconds({ seconds: elapsedSeconds }) + : isProcessing + ? "Saving" + : "Ready"} + </div> + </div> + <Button + variant={isRecording ? "secondary" : "default"} + size="sm" + disabled={isProcessing} + onClick={handleRecordClick} + className={cn( + "gap-1.5 rounded-sm", + isRecording ? "text-destructive" : "", + )} + > + <HugeiconsIcon + icon={isRecording ? StopIcon : MicIcon} + className="size-4" + /> + {isProcessing ? "Saving" : isRecording ? "Stop" : "Record"} + </Button> + </div> + + <VoiceOverLiveWaveform recording={isRecording} /> + + <div className="space-y-1.5"> + <div className="flex items-center justify-between gap-2"> + <div className="text-muted-foreground text-xs font-medium"> + Input + </div> + <Button + variant="text" + size="icon" + disabled={isLoadingDevices || isRecording} + onClick={() => refreshDevices({ requestPermission: true })} + className="size-6 rounded-sm" + > + <RefreshCw className="size-3.5" /> + </Button> + </div> + <Select + value={selectedValue} + disabled={isRecording} + onValueChange={(value) => + setSelectedDeviceId({ + deviceId: + value === DEFAULT_AUDIO_INPUT_DEVICE_ID ? null : value, + }) + } + > + <SelectTrigger className="w-full" variant="outline" size="sm"> + <SelectValue placeholder="System default microphone" /> + </SelectTrigger> + <SelectContent> + {devicesWithDefault.map((device) => ( + <SelectItem key={device.deviceId} value={device.deviceId}> + {device.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <div className="border-border border-t pt-3"> + <div className="text-muted-foreground mb-2 text-xs font-medium"> + Enhancements + </div> + <div className="space-y-2"> + {ENHANCEMENT_ORDER.map((id) => { + const option = VOICE_OVER_ENHANCEMENT_LABELS[id]; + return ( + <div + key={id} + className="flex items-start justify-between gap-3" + > + <span className="min-w-0"> + <span className="block text-sm">{option.label}</span> + <span className="text-muted-foreground block text-xs leading-snug"> + {option.description} + </span> + </span> + <Switch + checked={enhancements[id]} + aria-label={option.label} + onCheckedChange={(enabled) => + setEnhancementEnabled({ id, enabled }) + } + /> + </div> + ); + })} + </div> + </div> + </div> + </PopoverContent> + </Popover> + ); +} |
