feat(native): media capture (#9992)

This commit is contained in:
Brooooooklyn
2025-02-25 06:51:56 +00:00
parent 2ec7de7e32
commit 5dbffba08d
46 changed files with 5791 additions and 74 deletions
@@ -0,0 +1,122 @@
import React from 'react';
import type { AppGroup, RecordingStatus } from '../types';
import { formatDuration } from '../utils';
interface AppItemProps {
app: AppGroup;
recordings?: RecordingStatus[];
}
export function AppItem({ app, recordings }: AppItemProps) {
const [imgError, setImgError] = React.useState(false);
const [isRecording, setIsRecording] = React.useState(false);
const appName = app.rootApp.name || '';
const bundleId = app.rootApp.bundleIdentifier || '';
const firstLetter = appName.charAt(0).toUpperCase();
const isRunning = app.apps.some(a => a.running);
const recording = recordings?.find((r: RecordingStatus) =>
app.apps.some(a => a.processId === r.processId)
);
const handleRecordClick = React.useCallback(() => {
const recordingApp = app.apps.find(a => a.running);
if (!recordingApp) {
return;
}
if (isRecording) {
void fetch(`/api/apps/${recordingApp.processId}/stop`, {
method: 'POST',
})
.then(() => setIsRecording(false))
.catch(error => console.error('Failed to stop recording:', error));
} else {
void fetch(`/api/apps/${recordingApp.processId}/record`, {
method: 'POST',
})
.then(() => setIsRecording(true))
.catch(error => console.error('Failed to start recording:', error));
}
}, [app.apps, isRecording]);
React.useEffect(() => {
setIsRecording(!!recording);
}, [recording]);
const [duration, setDuration] = React.useState(0);
React.useEffect(() => {
if (recording) {
const interval = setInterval(() => {
setDuration(Date.now() - recording.startTime);
}, 1000);
return () => clearInterval(interval);
} else {
setDuration(0);
}
return () => {};
}, [recording]);
return (
<div className="flex items-center h-16 space-x-2 p-3 hover:bg-gray-50 rounded-lg transition-all duration-200 border border-transparent hover:border-gray-100">
{imgError ? (
<div className="w-8 h-8 rounded-lg bg-gray-50 border border-gray-100 flex items-center justify-center text-gray-600 font-semibold text-base">
{firstLetter}
</div>
) : (
<img
src={`/api/apps/${app.rootApp.processId}/icon`}
loading="lazy"
alt={appName}
className="w-8 h-8 object-contain rounded-lg bg-gray-50 border border-gray-100"
onError={() => setImgError(true)}
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-1 mb-1">
{appName ? (
<span className="text-gray-900 font-medium text-sm truncate">
{appName}
</span>
) : (
<span className="text-gray-400 italic font-medium text-sm">
Unnamed Application
</span>
)}
<span className="text-xs px-1 bg-gray-50 text-gray-500 rounded border border-gray-100">
PID: {app.rootApp.processId}
</span>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium border ${recording ? 'bg-red-50 text-red-600 border-red-100 opacity-100' : 'opacity-0'}`}
>
{recording ? formatDuration(duration) : '00:00:00'}
</span>
</div>
<div className="text-xs text-gray-500 font-mono truncate opacity-80">
{bundleId}
</div>
</div>
{(isRunning || isRecording) && (
<button
onClick={handleRecordClick}
className={`h-8 min-w-[80px] flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 ${
isRecording
? 'bg-red-50 text-red-600 hover:bg-red-100 border border-red-200'
: 'bg-blue-50 text-blue-600 hover:bg-blue-100 border border-blue-200'
}`}
>
{isRecording ? (
<>
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse mr-2" />
<span>Stop</span>
</>
) : (
<span>Record</span>
)}
</button>
)}
</div>
);
}
@@ -0,0 +1,144 @@
import React from 'react';
import useSWRSubscription from 'swr/subscription';
import type { App, AppGroup, RecordingStatus } from '../types';
import { socket } from '../utils';
import { AppItem } from './app-item';
export function AppList() {
const { data: apps = [] } = useSWRSubscription('apps', (_key, { next }) => {
let apps: App[] = [];
// Initial apps fetch
fetch('/api/apps')
.then(res => res.json())
.then(data => {
apps = data.apps;
next(null, apps);
})
.catch(err => next(err));
// Subscribe to app updates
socket.on('apps:all', data => {
next(null, data.apps);
apps = data.apps;
});
socket.on('apps:state-changed', data => {
const index = apps.findIndex(a => a.processId === data.processId);
if (index !== -1) {
next(
null,
apps.toSpliced(index, 1, {
...apps[index],
running: data.running,
})
);
}
});
socket.on('connect', () => {
// Refetch on reconnect
fetch('/api/apps')
.then(res => res.json())
.then(data => next(null, data.apps))
.catch(err => next(err));
});
return () => {
socket.off('apps:all');
socket.off('apps:state-changed');
socket.off('connect');
};
});
const { data: recordings = [] } = useSWRSubscription<RecordingStatus[]>(
'recordings',
(
_key: string,
{ next }: { next: (err: Error | null, data?: RecordingStatus[]) => void }
) => {
// Subscribe to recording updates
socket.on('apps:recording', (data: { recordings: RecordingStatus[] }) => {
next(null, data.recordings);
});
return () => {
socket.off('apps:recording');
};
}
);
const appGroups: AppGroup[] = React.useMemo(() => {
const mapping = apps.reduce((acc: Record<number, AppGroup>, app: App) => {
if (!acc[app.processGroupId]) {
acc[app.processGroupId] = {
processGroupId: app.processGroupId,
apps: [],
rootApp:
apps.find((a: App) => a.processId === app.processGroupId) || app,
};
}
acc[app.processGroupId].apps.push(app);
return acc;
}, {});
return Object.values(mapping);
}, [apps]);
const runningApps = (appGroups || []).filter(app =>
app.apps.some(a => a.running)
);
const notRunningApps = (appGroups || []).filter(
app => !app.apps.some(a => a.running)
);
return (
<div className="h-full flex flex-col divide-y divide-gray-100">
<div className="p-4 relative">
<div className="flex items-center justify-between sticky top-0 bg-white z-10 mb-2">
<h2 className="text-sm font-semibold text-gray-900">
Active Applications
</h2>
<span className="text-xs px-2 py-1 bg-blue-50 rounded-full text-blue-600 font-medium">
{runningApps.length} listening
</span>
</div>
<div className="space-y-2">
{runningApps.map(app => (
<AppItem
key={app.processGroupId}
app={app}
recordings={recordings}
/>
))}
{runningApps.length === 0 && (
<div className="text-sm text-gray-500 italic bg-gray-50 rounded-xl p-4 text-center">
No applications are currently listening
</div>
)}
</div>
</div>
<div className="p-4 flex-1 relative">
<div className="flex items-center justify-between sticky top-0 bg-white z-10 mb-2">
<h2 className="text-sm font-semibold text-gray-900">
Other Applications
</h2>
<span className="text-xs px-2 py-1 bg-gray-50 rounded-full text-gray-600 font-medium">
{notRunningApps.length} available
</span>
</div>
<div className="space-y-2">
{notRunningApps.map(app => (
<AppItem
key={app.processGroupId}
app={app}
recordings={recordings}
/>
))}
{notRunningApps.length === 0 && (
<div className="text-sm text-gray-500 italic bg-gray-50 rounded-xl p-4 text-center">
No other applications found
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,163 @@
import type { ReactElement } from 'react';
export function PlayIcon(): ReactElement {
return (
<svg
className="w-6 h-6 text-gray-900"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z"
fill="currentColor"
/>
</svg>
);
}
export function PauseIcon(): ReactElement {
return (
<svg
className="w-6 h-6 text-gray-900"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7 0a.75.75 0 01.75-.75h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75h-1.5a.75.75 0 01-.75-.75V5.25z"
fill="currentColor"
/>
</svg>
);
}
export function RewindIcon(): ReactElement {
return (
<svg
className="w-5 h-5 text-gray-600"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.334 4zM11 8a1 1 0 00-1.6-.8l-5.334 4a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8z"
fill="currentColor"
/>
</svg>
);
}
export function ForwardIcon(): ReactElement {
return (
<svg
className="w-5 h-5 text-gray-600"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 8a1 1 0 011.6-.8l5.334 4a1 1 0 010 1.6L6.6 16.8A1 1 0 015 16V8zm7.066-.8a1 1 0 00-1.6.8v8a1 1 0 001.6.8l5.334-4a1 1 0 000-1.6l-5.334-4z"
fill="currentColor"
/>
</svg>
);
}
export function DeleteIcon(): ReactElement {
return (
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function LoadingSpinner(): ReactElement {
return (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}
export function ErrorIcon(): ReactElement {
return (
<svg
className="w-4 h-4 mr-1.5 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
);
}
export function MicrophoneIcon(): ReactElement {
return (
<svg
className="w-4 h-4 mr-1.5 text-blue-500"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" />
</svg>
);
}
export function WarningIcon(): ReactElement {
return (
<svg className="w-4 h-4 mr-1.5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);
}
export function DefaultAppIcon(): ReactElement {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M10 2a3 3 0 00-3 3v4a3 3 0 006 0V5a3 3 0 00-3-3zm0 2a1 1 0 011 1v4a1 1 0 11-2 0V5a1 1 0 011-1z" />
<path d="M3 10a7 7 0 1014 0h-2a5 5 0 11-10 0H3z" />
</svg>
);
}
@@ -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>
);
}
@@ -0,0 +1,41 @@
import useSWRSubscription from 'swr/subscription';
import type { SavedRecording } from '../types';
import { socket } from '../utils';
import { SavedRecordingItem } from './saved-recording-item';
export function SavedRecordings(): React.ReactElement {
const { data: recordings = [] } = useSWRSubscription<SavedRecording[]>(
'saved-recordings',
(
_key: string,
{ next }: { next: (err: Error | null, data?: SavedRecording[]) => void }
) => {
// Subscribe to saved recordings updates
socket.on('apps:saved', (data: { recordings: SavedRecording[] }) => {
next(null, data.recordings);
});
fetch('/api/apps/saved')
.then(res => res.json())
.then(data => next(null, data.recordings))
.catch(err => next(err));
return () => {
socket.off('apps:saved');
};
}
);
if (recordings.length === 0) {
return <p className="text-gray-500 italic text-sm">No saved recordings</p>;
}
return (
<div className="space-y-1">
{recordings.map(recording => (
<SavedRecordingItem key={recording.wav} recording={recording} />
))}
</div>
);
}