feat: complete history, attendees list, and smart templates
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Mic, Square } from 'lucide-react';
|
||||
import { Mic, Square, Users, Headphones } from 'lucide-react';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import logo from '../assets/logo.png'; // Import logo
|
||||
|
||||
interface PromptTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
@@ -32,7 +34,12 @@ interface RecorderProps {
|
||||
onDeleteHistory: (id: string) => void;
|
||||
onLoadHistory: (item: HistoryItem) => void;
|
||||
savePath: string | null;
|
||||
|
||||
onRecordingComplete: () => void;
|
||||
autoStart?: boolean;
|
||||
recordingSubject?: string;
|
||||
onAutoStartHandled?: () => void;
|
||||
addToast: (msg: string, type: 'success' | 'error' | 'info', duration?: number) => void;
|
||||
}
|
||||
|
||||
interface AudioDevice {
|
||||
@@ -43,7 +50,8 @@ interface AudioDevice {
|
||||
const Recorder: React.FC<RecorderProps> = ({
|
||||
apiKey, productId, prompts,
|
||||
setTranscription, setSummary,
|
||||
onSaveToHistory, savePath, onRecordingComplete
|
||||
onSaveToHistory, savePath, onRecordingComplete,
|
||||
onOpenSettings, addToast, ...props
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
@@ -51,8 +59,17 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
const [selectedDevice, setSelectedDevice] = useState<string>('');
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
||||
const [selectedModel, setSelectedModel] = useState<string>('mixtral');
|
||||
const [recordingMode, setRecordingMode] = useState<'voice' | 'meeting'>('voice');
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
||||
const [lastSpeechTime, setLastSpeechTime] = useState<number>(Date.now());
|
||||
const [silenceDuration, setSilenceDuration] = useState(0);
|
||||
|
||||
// Filtered devices based on mode
|
||||
const filteredDevices = devices.filter(d => {
|
||||
const isVirtual = d.name.toLowerCase().includes('hearbit') || d.name.toLowerCase().includes('blackhole');
|
||||
return recordingMode === 'meeting' ? isVirtual : !isVirtual;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
@@ -95,12 +112,21 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
setDevices(aliasedDevs);
|
||||
|
||||
// Select Hearbit mic by default if available and no selection made
|
||||
// Smart Auto-select based on mode
|
||||
if (!selectedDevice) {
|
||||
const vb = aliasedDevs.find(d => d.name.includes('Hearbit Virtual Mic'));
|
||||
if (vb) {
|
||||
setSelectedDevice(vb.id);
|
||||
} else if (aliasedDevs.length > 0) {
|
||||
setSelectedDevice(aliasedDevs[0].id);
|
||||
// Prioritize "Hearbit Audio" (Aggregate) over "Hearbit Virtual Mic" (BlackHole)
|
||||
const aggregateDev = aliasedDevs.find(d => d.name === 'Hearbit Audio');
|
||||
const virtualDev = aliasedDevs.find(d => d.name.includes('Hearbit Virtual'));
|
||||
|
||||
if (aggregateDev) {
|
||||
setRecordingMode('meeting');
|
||||
setSelectedDevice(aggregateDev.id);
|
||||
} else if (virtualDev) {
|
||||
setRecordingMode('meeting');
|
||||
setSelectedDevice(virtualDev.id);
|
||||
} else {
|
||||
setRecordingMode('voice');
|
||||
if (aliasedDevs.length > 0) setSelectedDevice(aliasedDevs[0].id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -113,26 +139,114 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
await invoke('open_audio_midi_setup');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
addToast('Failed to open Audio Setup', 'error');
|
||||
setStatus('Failed to open Audio Setup');
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
const startRecording = async (deviceIdOverride?: string) => {
|
||||
try {
|
||||
setStatus('Starting...');
|
||||
await invoke('start_recording', { deviceId: selectedDevice, savePath: savePath || null });
|
||||
setStatus('Starting...');
|
||||
// Check override or state
|
||||
const targetDeviceId = deviceIdOverride || selectedDevice;
|
||||
|
||||
// Pass customFilename (camelCase key maps to snake_case in Rust automatically or we need to check Tauri mapping, usually it maps camel to camel? Rust expects snake. Let's use snake_case in invoke args to be safe)
|
||||
await invoke('start_recording', { deviceId: targetDeviceId, savePath: savePath || null, customFilename: props.recordingSubject || null });
|
||||
setIsRecording(true);
|
||||
setIsPaused(false);
|
||||
setTranscription('');
|
||||
setSummary('');
|
||||
setStatus('Recording...');
|
||||
addToast('Recording started', 'success', 2000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatus(`Error: ${e}`);
|
||||
addToast(`Error starting recording: ${e}`, 'error');
|
||||
setIsRecording(false);
|
||||
}
|
||||
};
|
||||
|
||||
// VAD & Auto-Stop Logic
|
||||
useEffect(() => {
|
||||
let unlisten: () => void;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ is_speech: boolean, probability: number }>('vad-event', (event) => {
|
||||
if (event.payload.is_speech) {
|
||||
setLastSpeechTime(Date.now());
|
||||
setSilenceDuration(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isRecording && !isPaused) {
|
||||
setupListener();
|
||||
setLastSpeechTime(Date.now()); // Reset on start
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (isRecording && !isPaused) {
|
||||
const diff = (Date.now() - lastSpeechTime) / 1000;
|
||||
setSilenceDuration(diff);
|
||||
|
||||
// Auto-stop after 30 seconds of silence
|
||||
if (diff > 30) { // 30 seconds
|
||||
console.log("Auto-stopping due to silence");
|
||||
setStatus("Auto-stopping (Silence detected)...");
|
||||
stopRecording();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isRecording, isPaused, lastSpeechTime]);
|
||||
|
||||
// Handle Auto Start Prop
|
||||
useEffect(() => {
|
||||
if (props.autoStart && !isRecording && devices.length > 0) {
|
||||
// Force meeting mode for auto-joins
|
||||
if (recordingMode !== 'meeting') {
|
||||
setRecordingMode('meeting');
|
||||
}
|
||||
|
||||
// Find best device (Race condition fix: we can't rely on selectedDevice state update being instant)
|
||||
const aggregateDev = devices.find(d => d.name === 'Hearbit Audio');
|
||||
const virtualDev = devices.find(d => d.name.includes('Hearbit Virtual'));
|
||||
const bestDevice = aggregateDev || virtualDev;
|
||||
|
||||
if (bestDevice) {
|
||||
setSelectedDevice(bestDevice.id); // Update UI state for consistency
|
||||
console.log("Auto-starting with device:", bestDevice.name);
|
||||
startRecording(bestDevice.id); // Pass ID directly
|
||||
} else {
|
||||
console.warn("Auto-start: No meeting device found, trying default.");
|
||||
startRecording();
|
||||
}
|
||||
|
||||
if (props.onAutoStartHandled) {
|
||||
props.onAutoStartHandled();
|
||||
}
|
||||
}
|
||||
}, [props.autoStart, devices]);
|
||||
|
||||
// Handle Custom Event (Legacy/Fallback)
|
||||
useEffect(() => {
|
||||
const handleStartReq = () => {
|
||||
if (!isRecording) {
|
||||
if (recordingMode !== 'meeting') {
|
||||
setRecordingMode('meeting');
|
||||
}
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
window.addEventListener('start-recording-req', handleStartReq);
|
||||
return () => window.removeEventListener('start-recording-req', handleStartReq);
|
||||
}, [isRecording, recordingMode]);
|
||||
|
||||
const togglePause = async () => {
|
||||
try {
|
||||
if (isPaused) {
|
||||
@@ -172,8 +286,40 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Find selected prompt content
|
||||
const activePrompt = prompts.find(p => p.id === selectedPromptId);
|
||||
// Find selected prompt content - SMART SELECTION
|
||||
let activePrompt = prompts.find(p => p.id === selectedPromptId);
|
||||
|
||||
// Smart Auto-Select based on keywords
|
||||
const lowerText = transText.toLowerCase();
|
||||
let bestMatchId = selectedPromptId;
|
||||
let maxMatches = 0;
|
||||
|
||||
for (const p of prompts) {
|
||||
if (!p.keywords) continue;
|
||||
let matches = 0;
|
||||
for (const kw of p.keywords) {
|
||||
if (lowerText.includes(kw.toLowerCase())) {
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
if (matches > maxMatches) {
|
||||
maxMatches = matches;
|
||||
bestMatchId = p.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatchId !== selectedPromptId) {
|
||||
const newPrompt = prompts.find(p => p.id === bestMatchId);
|
||||
if (newPrompt) {
|
||||
console.log(`Smart Select: Switched to '${newPrompt.name}' with ${maxMatches} matches.`);
|
||||
setStatus(`Smart Select: Using "${newPrompt.name}"...`);
|
||||
addToast(`Smart Select: Switched to "${newPrompt.name}"`, 'success', 4000);
|
||||
activePrompt = newPrompt;
|
||||
// Optional: Update UI selection? setSelectedPromptId(bestMatchId);
|
||||
// Let's verify with user preference? For now, we override as "Magic".
|
||||
}
|
||||
}
|
||||
|
||||
const promptContent = activePrompt ? activePrompt.content : "Summarize this.";
|
||||
|
||||
setStatus(`Summarizing (${selectedModel})...`);
|
||||
@@ -190,11 +336,13 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
onSaveToHistory(transText, sumText);
|
||||
|
||||
setStatus('Done!');
|
||||
addToast('Transcription & Summary complete!', 'success', 4000);
|
||||
onRecordingComplete(); // Auto-switch tab
|
||||
setTimeout(() => setStatus('Ready to record'), 3000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatus(`Error: ${e}`);
|
||||
addToast(`Error processing: ${e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,12 +375,17 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
|
||||
{status}
|
||||
{isRecording && !isPaused && silenceDuration > 10 && (
|
||||
<span className="block text-xs text-yellow-500 mt-1 opacity-80">
|
||||
Silence detected: {Math.floor(silenceDuration)}s (Auto-stop in {90 - Math.floor(silenceDuration)}s)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm space-y-4 mb-6 shrink-0">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
onClick={() => startRecording()}
|
||||
disabled={!apiKey || !productId}
|
||||
className="w-full py-4 text-lg font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
@@ -260,22 +413,43 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
Input Device
|
||||
</label>
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
</div>
|
||||
|
||||
{/* INPUT DEVICE SECTION */}
|
||||
<div className="col-span-2">
|
||||
<div className="flex bg-secondary p-1 rounded-lg mb-2">
|
||||
<button
|
||||
onClick={() => { setRecordingMode('voice'); setSelectedDevice(''); }}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-semibold rounded-md transition-all ${recordingMode === 'voice' ? 'bg-background shadow text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
{devices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{devices.length === 0 && <option value="">Loading devices...</option>}
|
||||
</select>
|
||||
<Headphones size={14} /> Voice Memo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRecordingMode('meeting'); setSelectedDevice(''); }}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-1.5 text-xs font-semibold rounded-md transition-all ${recordingMode === 'meeting' ? 'bg-background shadow text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
<Users size={14} /> Meeting
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{filteredDevices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{filteredDevices.length === 0 && (
|
||||
<option value="">
|
||||
{recordingMode === 'meeting' ? 'No Meeting Device (Create in Settings)' : 'No Microphone Found'}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
LLM Model
|
||||
@@ -311,11 +485,19 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-2 w-full">
|
||||
{recordingMode === 'meeting' && filteredDevices.length === 0 && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="text-xs bg-primary/10 text-primary hover:bg-primary/20 w-full text-center border border-primary/20 rounded p-2 mb-2 font-semibold"
|
||||
>
|
||||
🪄 Create "Hearbit Audio" Device
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={openAudioSetup}
|
||||
className="text-xs text-muted-foreground hover:text-foreground w-full text-center border border-dashed border-border/50 rounded p-1"
|
||||
>
|
||||
Open Audio MIDI Setup (Configure Multi-Output)
|
||||
Open Audio MIDI Setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user