summaryrefslogtreecommitdiff
path: root/apps/web/src/voiceover/voiceover-popover.tsx
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
committercashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
commitf1043734438c8518a302f698d9ae6583a75f0c14 (patch)
treed3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/voiceover/voiceover-popover.tsx
parent37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff)
downloadopencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/voiceover/voiceover-popover.tsx')
-rw-r--r--apps/web/src/voiceover/voiceover-popover.tsx276
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>
+ );
+}