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 ──
|
// ── Helpers ──
|
||||||
|
|
||||||
const blobToWav = async (blob: Blob): Promise<Blob> => {
|
export const blobToWav = async (blob: Blob): Promise<Blob> => {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { UploadProgress } from './components/UploadProgress';
|
|||||||
import { FileViewContainer } from './components/FileViewContainer';
|
import { FileViewContainer } from './components/FileViewContainer';
|
||||||
import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
||||||
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
|
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
|
||||||
|
import { DictateDialog } from './components/DictateDialog';
|
||||||
import { useFileBrowserApp } from './useFileBrowserApp';
|
import { useFileBrowserApp } from './useFileBrowserApp';
|
||||||
|
|
||||||
type DefaultSort = {
|
type DefaultSort = {
|
||||||
@@ -32,6 +33,7 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa
|
|||||||
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
||||||
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
|
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
|
||||||
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
|
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
|
||||||
|
<DictateDialog fileBrowserManager={fileBrowserManager} />
|
||||||
</div>
|
</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 { getIcon } from 'material-file-icons';
|
||||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
@@ -28,6 +28,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
|||||||
handleCreateDir,
|
handleCreateDir,
|
||||||
handleCreateDashboardHere,
|
handleCreateDashboardHere,
|
||||||
setShowVideoDownload,
|
setShowVideoDownload,
|
||||||
|
setShowDictate,
|
||||||
} = fileBrowserManager;
|
} = fileBrowserManager;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,6 +129,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
|||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download video
|
Download video
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => setShowDictate(true)} className="cursor-pointer">
|
||||||
|
<Mic className="mr-2 h-4 w-4" />
|
||||||
|
Dictate
|
||||||
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
|
|||||||
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
const [audioOnly, setAudioOnly] = useState(false);
|
const [audioOnly, setAudioOnly] = useState(false);
|
||||||
|
const [showDictate, setShowDictate] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const { getMatchingTasks } = useTasks();
|
const { getMatchingTasks } = useTasks();
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -688,6 +689,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
|
|||||||
showVideoDownload, setShowVideoDownload,
|
showVideoDownload, setShowVideoDownload,
|
||||||
videoUrl, setVideoUrl,
|
videoUrl, setVideoUrl,
|
||||||
audioOnly, setAudioOnly,
|
audioOnly, setAudioOnly,
|
||||||
|
// Dictate
|
||||||
|
showDictate, setShowDictate,
|
||||||
// Refs
|
// Refs
|
||||||
fileScrollRef,
|
fileScrollRef,
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|||||||
Reference in New Issue
Block a user