import type { ReactElement } from 'react'; import React from 'react'; import ReactMarkdown from 'react-markdown'; import type { SavedRecording, TranscriptionMetadata } from '../types'; import { formatDuration, socket } from '../utils'; import { DefaultAppIcon, DeleteIcon, ErrorIcon, ForwardIcon, LoadingSpinner, MicrophoneIcon, PauseIcon, PlayIcon, RewindIcon, WarningIcon, } from './icons'; interface SavedRecordingItemProps { recording: SavedRecording; } // Audio player controls component function AudioControls({ audioRef, playbackRate, onPlaybackRateChange, onSeek, onPlayPause, }: { audioRef: React.RefObject; playbackRate: number; onPlaybackRateChange: () => void; onSeek: (seconds: number) => void; onPlayPause: () => void; }): ReactElement { const [currentTime, setCurrentTime] = React.useState('00:00'); const [duration, setDuration] = React.useState('00:00'); React.useEffect(() => { const audio = audioRef.current; if (!audio) return; const formatTime = (time: number) => { const minutes = Math.floor(time / 60); const seconds = Math.floor(time % 60); return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; }; const updateTime = () => { setCurrentTime(formatTime(audio.currentTime)); setDuration(formatTime(audio.duration)); }; audio.addEventListener('timeupdate', updateTime); audio.addEventListener('loadedmetadata', updateTime); return () => { audio.removeEventListener('timeupdate', updateTime); audio.removeEventListener('loadedmetadata', updateTime); }; }, [audioRef]); return (
{currentTime} / {duration}
); } // Waveform visualization component function WaveformVisualizer({ containerRef, waveformData, currentTime, fileName, }: { containerRef: React.RefObject; waveformData: number[]; currentTime: number; fileName: string; }): ReactElement { return (
{waveformData.map((amplitude, i) => (
))}
); } // Update TranscriptionMessage component function TranscriptionMessage({ item, isNewSpeaker, isCurrentMessage, }: { item: { speaker: string; start_time: string; transcription: string; }; isNewSpeaker: boolean; isCurrentMessage: boolean; }): ReactElement { return (
{isNewSpeaker && (
{item.speaker}
)}
{item.start_time}
{item.transcription}
); } // Add new Summary component function TranscriptionSummary({ summary }: { summary: string }): ReactElement { return (
Summary
{summary}
); } // Update TranscriptionContent component function TranscriptionContent({ transcriptionData, currentAudioTime, }: { transcriptionData: { segments: Array<{ speaker: string; start_time: string; transcription: string; }>; summary: string; title: string; }; currentAudioTime: number; }): ReactElement { const parseTimestamp = (timestamp: string) => { // Handle "MM:SS" format (without hours) const [minutes, seconds] = timestamp.split(':'); return parseInt(minutes, 10) * 60 + parseInt(seconds, 10); }; return (
{transcriptionData.segments.map((item, index) => { const isNewSpeaker = index === 0 || transcriptionData.segments[index - 1].speaker !== item.speaker; const startTime = parseTimestamp(item.start_time); // Use next segment's start time as end time, or add 3 seconds for the last segment const endTime = index < transcriptionData.segments.length - 1 ? parseTimestamp(transcriptionData.segments[index + 1].start_time) : startTime + 3; const isCurrentMessage = currentAudioTime >= startTime && currentAudioTime < endTime; return ( ); })}
); } // Update TranscriptionStatus component function TranscriptionStatus({ transcription, transcriptionError, currentAudioTime, }: { transcription?: TranscriptionMetadata; transcriptionError: string | null; currentAudioTime: number; }): ReactElement | null { if (!transcription && !transcriptionError) { return null; } if (transcription?.transcriptionStatus === 'pending') { return (
Processing Audio
Starting transcription ...
This may take a few moments depending on the length of the recording
); } if (transcriptionError) { return (
{transcriptionError}
); } if ( transcription?.transcriptionStatus === 'completed' && transcription.transcription ) { try { const transcriptionData = transcription.transcription; if ( !transcriptionData.segments || !Array.isArray(transcriptionData.segments) ) { throw new Error('Invalid transcription data format'); } return (
Conversation Transcript
{transcriptionData.title && (
Title
{transcriptionData.title}
)}
); } catch (error) { return (
{error instanceof Error ? error.message : 'Failed to parse transcription data'}
); } } return null; } // Add new RecordingHeader component function RecordingHeader({ metadata, fileName, recordingDate, duration, error, isDeleting, showDeleteConfirm, setShowDeleteConfirm, handleDeleteClick, }: { metadata: SavedRecording['metadata']; fileName: string; recordingDate: string; duration: string; error: string | null; isDeleting: boolean; showDeleteConfirm: boolean; setShowDeleteConfirm: (show: boolean) => void; handleDeleteClick: () => void; transcriptionError: string | null; }): ReactElement { const [imgError, setImgError] = React.useState(false); const isGlobalRecording = metadata?.isGlobal; return (
{!imgError && !isGlobalRecording ? ( {metadata?.appName setImgError(true)} /> ) : (
)}
{metadata?.appName || 'Unknown Application'} {isGlobalRecording && ( System Audio )} {duration}
{showDeleteConfirm ? (
) : ( )}
{recordingDate}
{metadata?.bundleIdentifier || fileName}
{error && (
{error}
)}
); } // Add new AudioPlayer component function AudioPlayer({ isLoading, error, audioRef, playbackRate, handlePlaybackRateChange, handleSeek, handlePlayPause, containerRef, waveformData, currentTime, fileName, }: { isLoading: boolean; error: string | null; audioRef: React.RefObject; playbackRate: number; handlePlaybackRateChange: () => void; handleSeek: (seconds: number) => void; handlePlayPause: () => void; containerRef: React.RefObject; waveformData: number[]; currentTime: number; fileName: string; }): ReactElement { return (
{isLoading && !error ? (
Loading audio...
) : (
)}
); } // Add new TranscribeButton component function TranscribeButton({ transcriptionStatus, onTranscribe, }: { transcriptionStatus?: TranscriptionMetadata['transcriptionStatus']; onTranscribe: () => void; }): ReactElement { return (
); } // Main SavedRecordingItem component (simplified) export function SavedRecordingItem({ recording, }: SavedRecordingItemProps): ReactElement { const [error, setError] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); const [isDeleting, setIsDeleting] = React.useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const [playbackRate, setPlaybackRate] = React.useState(1); const [waveformData, setWaveformData] = React.useState([]); const [currentTime, setCurrentTime] = React.useState(0); const audioRef = React.useRef(null); const containerRef = React.useRef(null); const [segments, setSegments] = React.useState(40); const [currentAudioTime, setCurrentAudioTime] = React.useState(0); const [transcriptionError, setTranscriptionError] = React.useState< string | null >(null); const metadata = recording.metadata; // Ensure we have a valid filename, fallback to an empty string if undefined const fileName = recording.wav || ''; const recordingDate = metadata ? new Date(metadata.recordingStartTime).toLocaleString() : 'Unknown date'; const duration = metadata ? formatDuration(metadata.recordingDuration * 1000) : 'Unknown duration'; // Update current audio time React.useEffect(() => { const audio = audioRef.current; if (audio) { const handleTimeUpdate = () => { setCurrentAudioTime(audio.currentTime); }; audio.addEventListener('timeupdate', handleTimeUpdate); return () => audio.removeEventListener('timeupdate', handleTimeUpdate); } return () => {}; }, []); // Calculate number of segments based on container width React.useEffect(() => { const updateSegments = () => { if (containerRef.current) { // Each bar should be at least 2px wide (1px bar + 1px gap) const width = containerRef.current.offsetWidth; setSegments(Math.floor(width / 2)); } }; updateSegments(); const resizeObserver = new ResizeObserver(updateSegments); if (containerRef.current) { resizeObserver.observe(containerRef.current); } return () => resizeObserver.disconnect(); }, []); const processAudioData = React.useCallback(async () => { try { // Check if fileName is empty if (!fileName) { throw new Error('Invalid recording filename'); } const response = await fetch(`/api/recordings/${fileName}/recording.wav`); if (!response.ok) { throw new Error( `Failed to fetch audio file (${response.status}): ${response.statusText}` ); } const audioContext = new AudioContext(); const arrayBuffer = await response.arrayBuffer(); // Ensure we have data to process if (!arrayBuffer || arrayBuffer.byteLength === 0) { throw new Error('No audio data received'); } const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); const channelData = audioBuffer.getChannelData(0); // Process the audio data in chunks to create the waveform const numberOfSamples = channelData.length; const samplesPerSegment = Math.floor(numberOfSamples / segments); const waveform: number[] = []; for (let i = 0; i < segments; i++) { const start = i * samplesPerSegment; const end = start + samplesPerSegment; const segmentData = channelData.slice(start, end); // Calculate RMS (root mean square) for better amplitude representation const rms = Math.sqrt( segmentData.reduce((sum, sample) => sum + sample * sample, 0) / segmentData.length ); waveform.push(rms); } // Normalize the waveform data to a 0-1 range const maxAmplitude = Math.max(...waveform); const normalizedWaveform = waveform.map(amp => amp / maxAmplitude); setWaveformData(normalizedWaveform); setIsLoading(false); } catch (err) { console.error('Error processing audio:', err); setError( err instanceof Error ? err.message : 'Failed to process audio data' ); setIsLoading(false); } }, [fileName, segments]); React.useEffect(() => { const audio = audioRef.current; if (audio) { const handleError = (e: ErrorEvent) => { console.error('Audio error:', e); setError('Failed to load audio'); setIsLoading(false); }; const handleLoadedMetadata = () => { void processAudioData().catch(err => { console.error('Error processing audio data:', err); setError('Failed to process audio data'); setIsLoading(false); }); }; const handleTimeUpdate = () => { setCurrentTime(audio.currentTime / audio.duration); }; audio.addEventListener('error', handleError as EventListener); audio.addEventListener('loadedmetadata', handleLoadedMetadata); audio.addEventListener('timeupdate', handleTimeUpdate); return () => { audio.removeEventListener('error', handleError as EventListener); audio.removeEventListener('loadedmetadata', handleLoadedMetadata); audio.removeEventListener('timeupdate', handleTimeUpdate); }; } return () => {}; }, [processAudioData]); const handlePlayPause = React.useCallback(() => { if (audioRef.current) { if (audioRef.current.paused) { // oxlint-disable-next-line @typescript-eslint/no-floating-promises void audioRef.current.play(); } else { audioRef.current.pause(); } } }, []); const handleSeek = React.useCallback((seconds: number) => { if (audioRef.current) { audioRef.current.currentTime += seconds; } }, []); const handlePlaybackRateChange = React.useCallback(() => { if (audioRef.current) { const newRate = playbackRate === 1 ? 1.5 : 1; audioRef.current.playbackRate = newRate; setPlaybackRate(newRate); } }, [playbackRate]); const handleDelete = React.useCallback(async () => { setIsDeleting(true); setError(null); // Clear any previous errors try { // Check if filename is valid if (!recording.wav) { throw new Error('Invalid recording filename'); } const response = await fetch(`/api/recordings/${recording.wav}`, { method: 'DELETE', }); if (!response.ok) { let errorMessage: string; try { const errorData = await response.json(); errorMessage = errorData.error; } catch { errorMessage = `Server error (${response.status}): ${response.statusText}`; } throw new Error(errorMessage); } setShowDeleteConfirm(false); } catch (err) { console.error('Error deleting recording:', err); setError( err instanceof Error ? err.message : 'An unexpected error occurred' ); } finally { setIsDeleting(false); } }, [recording.wav]); const handleDeleteClick = React.useCallback(() => { void handleDelete().catch(err => { console.error('Unexpected error during deletion:', err); setError('An unexpected error occurred'); }); }, [handleDelete]); React.useEffect(() => { // Listen for transcription events socket.on( 'apps:recording-transcription-start', (data: { filename: string }) => { if (recording.wav && data.filename === recording.wav) { setTranscriptionError(null); } } ); socket.on( 'apps:recording-transcription-end', (data: { filename: string; success: boolean; transcription?: string; error?: string; }) => { if (recording.wav && data.filename === recording.wav && !data.success) { setTranscriptionError(data.error || 'Transcription failed'); } } ); return () => { socket.off('apps:recording-transcription-start'); socket.off('apps:recording-transcription-end'); }; }, [recording.wav]); const handleTranscribe = React.useCallback(async () => { try { // Check if filename is valid if (!recording.wav) { throw new Error('Invalid recording filename'); } const response = await fetch( `/api/recordings/${recording.wav}/transcribe`, { method: 'POST', } ); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to start transcription'); } } catch (err) { setTranscriptionError( err instanceof Error ? err.message : 'Failed to start transcription' ); } }, [recording.wav]); return (
} playbackRate={playbackRate} handlePlaybackRateChange={handlePlaybackRateChange} handleSeek={handleSeek} handlePlayPause={handlePlayPause} containerRef={containerRef as React.RefObject} waveformData={waveformData} currentTime={currentTime} fileName={fileName} />
); }