From f1043734438c8518a302f698d9ae6583a75f0c14 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 16 Aug 2026 06:16:56 +0200 Subject: feat: add transitions, track mixing, and voiceover controls --- apps/web/src/voiceover/voiceover-popover.tsx | 276 +++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 apps/web/src/voiceover/voiceover-popover.tsx (limited to 'apps/web/src/voiceover/voiceover-popover.tsx') 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 = [ + "voiceBoost", + "noiseGate", + "highPass", + "deEss", + "limiter", + "normalize", +]; + +export function VoiceOverRecorderPopover() { + const [open, setOpen] = useState(false); + const [devices, setDevices] = useState([]); + 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 ( + + + + + + + + + + {isProcessing + ? "Saving voice-over" + : isRecording + ? "Stop voice-over" + : "Record voice-over"} + + + + + event.stopPropagation()} + > +
+
+
+
Voice-over
+
+ {isRecording + ? formatElapsedSeconds({ seconds: elapsedSeconds }) + : isProcessing + ? "Saving" + : "Ready"} +
+
+ +
+ + + +
+
+
+ Input +
+ +
+ +
+ +
+
+ Enhancements +
+
+ {ENHANCEMENT_ORDER.map((id) => { + const option = VOICE_OVER_ENHANCEMENT_LABELS[id]; + return ( +
+ + {option.label} + + {option.description} + + + + setEnhancementEnabled({ id, enabled }) + } + /> +
+ ); + })} +
+
+
+
+
+ ); +} -- cgit v1.3.1