add dictate option to file browser context menu
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -92,7 +92,7 @@ export type UseAudioRecordingType = ReturnType<typeof useAudioRecording>;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const blobToWav = async (blob: Blob): Promise<Blob> => {
|
||||
export const blobToWav = async (blob: Blob): Promise<Blob> => {
|
||||
const ctx = new AudioContext();
|
||||
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
||||
await ctx.close();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UploadProgress } from './components/UploadProgress';
|
||||
import { FileViewContainer } from './components/FileViewContainer';
|
||||
import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
||||
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
|
||||
import { DictateDialog } from './components/DictateDialog';
|
||||
import { useFileBrowserApp } from './useFileBrowserApp';
|
||||
|
||||
type DefaultSort = {
|
||||
@@ -32,6 +33,7 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa
|
||||
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
||||
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
|
||||
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
|
||||
<DictateDialog fileBrowserManager={fileBrowserManager} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { blobToWav } from '../../../Chat/useAudioRecording';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
|
||||
type DictateDialogProps = {
|
||||
fileBrowserManager: UseFileBrowserAppType;
|
||||
};
|
||||
|
||||
export const DictateDialog = ({ fileBrowserManager }: DictateDialogProps) => {
|
||||
const { showDictate, setShowDictate, handleUpload } = fileBrowserManager;
|
||||
const [recording, setRecording] = useState(false);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
const cleanup = () => {
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
audioCtxRef.current?.close();
|
||||
mediaRecorderRef.current = null;
|
||||
streamRef.current = null;
|
||||
audioCtxRef.current = null;
|
||||
analyserRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setRecording(false);
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
const audioCtx = new AudioContext();
|
||||
audioCtxRef.current = audioCtx;
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
const analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const recorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = recorder;
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (ev) => {
|
||||
if (ev.data.size > 0) chunksRef.current.push(ev.data);
|
||||
};
|
||||
recorder.start(250);
|
||||
setRecording(true);
|
||||
|
||||
drawSpectrum();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
|
||||
setShowDictate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const drawSpectrum = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const analyser = analyserRef.current;
|
||||
if (!canvas || !analyser) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
const draw = () => {
|
||||
animFrameRef.current = requestAnimationFrame(draw);
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const barWidth = (w / bufferLength) * 2;
|
||||
let x = 0;
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = (dataArray[i]! / 255) * h;
|
||||
ctx.fillStyle = `hsl(174, 60%, ${40 + (dataArray[i]! / 255) * 30}%)`;
|
||||
ctx.fillRect(x, h - barHeight, barWidth - 1, barHeight);
|
||||
x += barWidth;
|
||||
}
|
||||
};
|
||||
|
||||
draw();
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
const recorder = mediaRecorderRef.current;
|
||||
if (!recorder || recorder.state === 'inactive') {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000);
|
||||
recorder.onstop = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(new Blob(chunksRef.current, { type: recorder.mimeType }));
|
||||
chunksRef.current = [];
|
||||
};
|
||||
recorder.stop();
|
||||
});
|
||||
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
recorder.stream.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
audioCtxRef.current?.close();
|
||||
|
||||
if (blob.size === 0) {
|
||||
toast.error('No audio was captured');
|
||||
cleanup();
|
||||
setShowDictate(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const wav = await blobToWav(blob);
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
const file = new File([wav], `dictation_${ts}.wav`, { type: 'audio/wav' });
|
||||
|
||||
setShowDictate(false);
|
||||
setRecording(false);
|
||||
await handleUpload([file]);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Recording failed');
|
||||
cleanup();
|
||||
setShowDictate(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showDictate) {
|
||||
startRecording();
|
||||
}
|
||||
return () => {
|
||||
if (!showDictate) cleanup();
|
||||
};
|
||||
}, [showDictate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDictate || !recording) return;
|
||||
const handler = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
stopRecording();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [showDictate, recording]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={showDictate}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
cleanup();
|
||||
setShowDictate(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dictate</DialogTitle>
|
||||
<DialogDescription>Recording audio. Press Enter or click Stop to save.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<canvas ref={canvasRef} width={460} height={120} className="w-full rounded-md bg-duck-dark/5" />
|
||||
<div className="flex items-center gap-2">
|
||||
{recording && <span className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />}
|
||||
<span className="text-sm text-duck-dark/60">{recording ? 'Recording...' : 'Starting...'}</span>
|
||||
</div>
|
||||
<div className="flex justify-end w-full gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
cleanup();
|
||||
setShowDictate(false);
|
||||
}}
|
||||
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopRecording}
|
||||
disabled={!recording}
|
||||
className="px-4 py-2 text-sm rounded-md bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Stop & Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download } from 'lucide-react';
|
||||
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download, Mic } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
@@ -28,6 +28,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
handleCreateDir,
|
||||
handleCreateDashboardHere,
|
||||
setShowVideoDownload,
|
||||
setShowDictate,
|
||||
} = fileBrowserManager;
|
||||
|
||||
return (
|
||||
@@ -128,6 +129,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download video
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowDictate(true)} className="cursor-pointer">
|
||||
<Mic className="mr-2 h-4 w-4" />
|
||||
Dictate
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
@@ -43,6 +43,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
|
||||
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [audioOnly, setAudioOnly] = useState(false);
|
||||
const [showDictate, setShowDictate] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
const { getMatchingTasks } = useTasks();
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -688,6 +689,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
|
||||
showVideoDownload, setShowVideoDownload,
|
||||
videoUrl, setVideoUrl,
|
||||
audioOnly, setAudioOnly,
|
||||
// Dictate
|
||||
showDictate, setShowDictate,
|
||||
// Refs
|
||||
fileScrollRef,
|
||||
// Handlers
|
||||
|
||||
Reference in New Issue
Block a user