feat(native): media capture (#9992)
This commit is contained in:
@@ -0,0 +1,872 @@
|
||||
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<HTMLAudioElement | null>;
|
||||
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 (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onSeek(-15)}
|
||||
className="p-2 hover:bg-gray-50 rounded-lg transition-all duration-200 border border-transparent hover:border-gray-100 hover:shadow-sm"
|
||||
title="Back 15 seconds"
|
||||
>
|
||||
<RewindIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPlayPause}
|
||||
className="p-2 hover:bg-gray-50 rounded-lg transition-all duration-200 border border-transparent hover:border-gray-100 hover:shadow-sm"
|
||||
>
|
||||
{audioRef.current?.paused ? <PlayIcon /> : <PauseIcon />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSeek(30)}
|
||||
className="p-2 hover:bg-gray-50 rounded-lg transition-all duration-200 border border-transparent hover:border-gray-100 hover:shadow-sm"
|
||||
title="Forward 30 seconds"
|
||||
>
|
||||
<ForwardIcon />
|
||||
</button>
|
||||
<div className="text-sm font-mono text-gray-500 ml-2">
|
||||
{currentTime} <span className="text-gray-400">/</span> {duration}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onPlaybackRateChange}
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-50 hover:bg-gray-100 rounded-lg transition-all duration-200 border border-gray-100 hover:shadow-sm"
|
||||
>
|
||||
{playbackRate}x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Waveform visualization component
|
||||
function WaveformVisualizer({
|
||||
containerRef,
|
||||
waveformData,
|
||||
currentTime,
|
||||
fileName,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
waveformData: number[];
|
||||
currentTime: number;
|
||||
fileName: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="relative h-14 bg-gray-50 overflow-hidden rounded-lg border border-gray-100"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-end">
|
||||
{waveformData.map((amplitude, i) => (
|
||||
<div
|
||||
key={`${fileName}-bar-${i}`}
|
||||
className="flex-1 bg-red-400 transition-all duration-200"
|
||||
style={{
|
||||
height: `${Math.max(amplitude * 100, 3)}%`,
|
||||
opacity:
|
||||
i < Math.floor(currentTime * waveformData.length) ? 1 : 0.3,
|
||||
margin: '0 0.5px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Update TranscriptionMessage component
|
||||
function TranscriptionMessage({
|
||||
item,
|
||||
isNewSpeaker,
|
||||
isCurrentMessage,
|
||||
}: {
|
||||
item: {
|
||||
speaker: string;
|
||||
start_time: string;
|
||||
transcription: string;
|
||||
};
|
||||
isNewSpeaker: boolean;
|
||||
isCurrentMessage: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="flex items-start gap-3 group transition-all duration-300 w-full">
|
||||
<div className="w-[120px] flex-shrink-0">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{isNewSpeaker && (
|
||||
<div
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors duration-300 ${
|
||||
isCurrentMessage
|
||||
? 'bg-blue-100 text-blue-700 border-blue-200'
|
||||
: 'bg-blue-50 text-blue-600 border-blue-100'
|
||||
}`}
|
||||
>
|
||||
{item.speaker}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`text-[11px] font-mono ml-2 transition-colors duration-300 ${
|
||||
isCurrentMessage ? 'text-blue-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{item.start_time}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<div
|
||||
className={`text-sm leading-relaxed rounded-xl px-4 py-2 border transition-all inline-flex duration-300 ${
|
||||
isCurrentMessage
|
||||
? 'bg-blue-50/50 text-blue-900 border-blue-200 shadow-md'
|
||||
: 'bg-white text-gray-600 border-gray-100 shadow-sm hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{item.transcription}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add new Summary component
|
||||
function TranscriptionSummary({ summary }: { summary: string }): ReactElement {
|
||||
return (
|
||||
<div className="mb-6 bg-blue-50/50 rounded-xl p-4 border border-blue-100">
|
||||
<div className="text-xs font-medium text-blue-600 mb-2 uppercase tracking-wider">
|
||||
Summary
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none prose-headings:text-gray-900 prose-a:text-blue-600 whitespace-pre-wrap">
|
||||
<ReactMarkdown>{summary}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-2 py-2 max-h-[400px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent hover:scrollbar-thumb-gray-400 w-full">
|
||||
<TranscriptionSummary summary={transcriptionData.summary} />
|
||||
{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 (
|
||||
<TranscriptionMessage
|
||||
key={`${item.speaker}-${item.start_time}-${index}`}
|
||||
item={item}
|
||||
isNewSpeaker={isNewSpeaker}
|
||||
isCurrentMessage={isCurrentMessage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="my-2">
|
||||
<div className="text-sm text-gray-600 bg-gray-50/50 p-4 border border-gray-100 w-full">
|
||||
<div className="font-medium text-gray-900 mb-4 flex items-center sticky top-0 bg-gray-50/50 backdrop-blur-sm z-10 py-2">
|
||||
<MicrophoneIcon />
|
||||
<span>Processing Audio</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<LoadingSpinner />
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Starting transcription</span>
|
||||
<span className="text-gray-400 animate-pulse">...</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 max-w-sm text-center">
|
||||
This may take a few moments depending on the length of the
|
||||
recording
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (transcriptionError) {
|
||||
return (
|
||||
<div className="text-xs text-red-500 m-2 flex items-center bg-red-50 rounded-lg p-2 border border-red-100">
|
||||
<ErrorIcon />
|
||||
{transcriptionError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="my-2">
|
||||
<div className="text-sm text-gray-600 bg-gray-50/50 p-4 border border-gray-100 w-full">
|
||||
<div className="font-medium text-gray-900 mb-4 flex items-center sticky top-0 bg-gray-50/50 backdrop-blur-sm z-10 py-2">
|
||||
<MicrophoneIcon />
|
||||
<span>Conversation Transcript</span>
|
||||
</div>
|
||||
{transcriptionData.title && (
|
||||
<div className="mb-4 bg-blue-50/50 rounded-lg p-3 border border-blue-100">
|
||||
<div className="text-xs font-medium text-blue-600 uppercase tracking-wider mb-1">
|
||||
Title
|
||||
</div>
|
||||
<div className="text-base font-medium text-gray-900">
|
||||
{transcriptionData.title}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TranscriptionContent
|
||||
transcriptionData={transcriptionData}
|
||||
currentAudioTime={currentAudioTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
return (
|
||||
<div className="text-sm text-red-500 bg-red-50 rounded-lg p-2 border border-red-100 m-2">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to parse transcription data'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="flex items-start space-x-4 p-4 bg-gray-50/30">
|
||||
<div className="relative w-12 h-12 flex-shrink-0">
|
||||
{!imgError ? (
|
||||
<img
|
||||
src={`/api/recordings/${fileName}/icon.png`}
|
||||
alt={metadata?.appName || 'Unknown Application'}
|
||||
className="w-12 h-12 object-contain rounded-lg bg-gray-50 border border-gray-100 shadow-sm transition-transform duration-200 hover:scale-105"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-gray-500 bg-gray-50 border border-gray-100 shadow-sm">
|
||||
<DefaultAppIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-900 font-semibold text-base truncate">
|
||||
{metadata?.appName || 'Unknown Application'}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-50 rounded-full text-blue-600 font-medium border border-blue-100">
|
||||
{duration}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{showDeleteConfirm ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="h-8 px-3 text-sm font-medium text-gray-600 hover:bg-gray-50 rounded-lg transition-colors border border-gray-100"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="h-8 px-3 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors border border-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<LoadingSpinner />
|
||||
<span>Deleting...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Confirm'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="h-8 w-8 flex items-center justify-center text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete recording"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">{recordingDate}</div>
|
||||
<div className="text-xs text-gray-400 font-mono mt-0.5 truncate">
|
||||
{metadata?.bundleIdentifier || fileName}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 mt-2 flex items-center bg-red-50 rounded-lg p-2 border border-red-100">
|
||||
<ErrorIcon />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<HTMLAudioElement>;
|
||||
playbackRate: number;
|
||||
handlePlaybackRateChange: () => void;
|
||||
handleSeek: (seconds: number) => void;
|
||||
handlePlayPause: () => void;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
waveformData: number[];
|
||||
currentTime: number;
|
||||
fileName: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
{isLoading && !error ? (
|
||||
<div className="h-14 bg-gray-50 rounded-lg flex items-center justify-center border border-gray-100">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-sm text-gray-600 font-medium">
|
||||
Loading audio...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-3">
|
||||
<AudioControls
|
||||
audioRef={audioRef}
|
||||
playbackRate={playbackRate}
|
||||
onPlaybackRateChange={handlePlaybackRateChange}
|
||||
onSeek={handleSeek}
|
||||
onPlayPause={handlePlayPause}
|
||||
/>
|
||||
<WaveformVisualizer
|
||||
containerRef={containerRef}
|
||||
waveformData={waveformData}
|
||||
currentTime={currentTime}
|
||||
fileName={fileName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add new TranscribeButton component
|
||||
function TranscribeButton({
|
||||
transcriptionStatus,
|
||||
onTranscribe,
|
||||
}: {
|
||||
transcriptionStatus?: TranscriptionMetadata['transcriptionStatus'];
|
||||
onTranscribe: () => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onTranscribe}
|
||||
disabled={transcriptionStatus === 'pending'}
|
||||
className={`h-8 px-3 text-sm font-medium rounded-lg transition-colors border flex items-center space-x-2
|
||||
${
|
||||
transcriptionStatus === 'pending'
|
||||
? 'bg-blue-50 text-blue-600 border-blue-200 cursor-not-allowed'
|
||||
: transcriptionStatus === 'completed'
|
||||
? 'text-blue-600 hover:bg-blue-50 border-blue-100'
|
||||
: transcriptionStatus === 'error'
|
||||
? 'text-red-600 hover:bg-red-50 border-red-100'
|
||||
: 'text-blue-600 hover:bg-blue-50 border-blue-100'
|
||||
}`}
|
||||
>
|
||||
{transcriptionStatus === 'pending' ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
<span>Transcribing...</span>
|
||||
</>
|
||||
) : transcriptionStatus === 'completed' ? (
|
||||
<>
|
||||
<MicrophoneIcon />
|
||||
<span>Transcribe Again</span>
|
||||
</>
|
||||
) : transcriptionStatus === 'error' ? (
|
||||
<>
|
||||
<WarningIcon />
|
||||
<span>Retry Transcription</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MicrophoneIcon />
|
||||
<span>Transcribe</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main SavedRecordingItem component (simplified)
|
||||
export function SavedRecordingItem({
|
||||
recording,
|
||||
}: SavedRecordingItemProps): ReactElement {
|
||||
const [error, setError] = React.useState<string | null>(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<number[]>([]);
|
||||
const [currentTime, setCurrentTime] = React.useState(0);
|
||||
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(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;
|
||||
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 {
|
||||
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) {
|
||||
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 {
|
||||
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 (data.filename === recording.wav) {
|
||||
setTranscriptionError(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
socket.on(
|
||||
'apps:recording-transcription-end',
|
||||
(data: {
|
||||
filename: string;
|
||||
success: boolean;
|
||||
transcription?: string;
|
||||
error?: string;
|
||||
}) => {
|
||||
if (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 {
|
||||
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 (
|
||||
<div className="bg-white rounded-lg shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden mb-3 border border-gray-100 hover:border-gray-200">
|
||||
<RecordingHeader
|
||||
metadata={metadata}
|
||||
fileName={fileName}
|
||||
recordingDate={recordingDate}
|
||||
duration={duration}
|
||||
error={error}
|
||||
isDeleting={isDeleting}
|
||||
showDeleteConfirm={showDeleteConfirm}
|
||||
setShowDeleteConfirm={setShowDeleteConfirm}
|
||||
handleDeleteClick={handleDeleteClick}
|
||||
transcriptionError={transcriptionError}
|
||||
/>
|
||||
<AudioPlayer
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
audioRef={audioRef as React.RefObject<HTMLAudioElement>}
|
||||
playbackRate={playbackRate}
|
||||
handlePlaybackRateChange={handlePlaybackRateChange}
|
||||
handleSeek={handleSeek}
|
||||
handlePlayPause={handlePlayPause}
|
||||
containerRef={containerRef as React.RefObject<HTMLDivElement>}
|
||||
waveformData={waveformData}
|
||||
currentTime={currentTime}
|
||||
fileName={fileName}
|
||||
/>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={`/api/recordings/${fileName}/recording.wav`}
|
||||
preload="metadata"
|
||||
className="hidden"
|
||||
/>
|
||||
<TranscriptionStatus
|
||||
transcription={recording.transcription}
|
||||
transcriptionError={transcriptionError}
|
||||
currentAudioTime={currentAudioTime}
|
||||
/>
|
||||
<TranscribeButton
|
||||
transcriptionStatus={recording.transcription?.transcriptionStatus}
|
||||
onTranscribe={() => void handleTranscribe()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user