Files
wisp/src/app/index.tsx
T
NilsBriggen 6d2f583136
CI / test (push) Successful in 17s
CI / build-apk (push) Has been skipped
CI / deploy-web (push) Successful in 28s
feat(phase2): in-app live recording into courses (web)
- useRecorder: MediaRecorder mic capture with elapsed timer, live input-level
  meter (AnalyserNode), pause/resume, and a screen Wake Lock; returns the
  recording as an ArrayBuffer that flows into the existing transcribe pipeline.
- Record screen: big timer + level meter + start/pause/resume/stop; on stop the
  pre-capture sheet collects title/course/date, then it transcribes + saves +
  indexes (reusing Phase 0/1). Home gets Record + Import buttons.
- Web-only for now; native realtime (whisper.rn), OPFS crash-recovery,
  tab-audio capture, and the per-course dashboard are follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:53:15 +02:00

269 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Link, useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
TextInput,
View,
} from 'react-native';
import { PreCaptureSheet, type PreCaptureResult } from '@/components/PreCaptureSheet';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme';
import type { TranscriptMeta } from '@/lib/db';
import { formatClock } from '@/lib/format';
import { MODELS } from '@/lib/models/catalog';
import { pickAudio, type PickedAudio } from '@/lib/pickAudio';
import { useCourses } from '@/stores/coursesStore';
import { useTranscribe } from '@/stores/transcribeStore';
import { useTranscripts, type CourseFilter } from '@/stores/transcriptsStore';
export default function LibraryScreen() {
const theme = useTheme();
const router = useRouter();
const { items, loading, query, setQuery, refresh, remove, courseFilter, setCourseFilter } =
useTranscripts();
const courses = useCourses((s) => s.items);
const refreshCourses = useCourses((s) => s.refresh);
const job = useTranscribe();
const [picked, setPicked] = useState<PickedAudio | null>(null);
useFocusEffect(
useCallback(() => {
void refresh();
void refreshCourses();
}, [refresh, refreshCourses]),
);
const busy = job.status === 'loading' || job.status === 'transcribing';
const onNew = useCallback(async () => {
if (busy) return;
const p = await pickAudio();
if (p) setPicked(p); // opens the pre-capture sheet
}, [busy]);
const onStart = useCallback(
async (r: PreCaptureResult) => {
const p = picked;
setPicked(null);
if (!p) return;
const id = await useTranscribe.getState().start(p, r);
if (id) router.push({ pathname: '/transcript/[id]', params: { id } });
},
[picked, router],
);
const courseName = (cid?: string | null) =>
cid ? courses.find((c) => c.id === cid)?.name ?? 'Course' : null;
return (
<ThemedView style={styles.fill}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.headerRow}>
<View style={styles.flex}>
<ThemedText type="subtitle">Wisp</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
Private transcription runs on your device, nothing uploaded.
</ThemedText>
</View>
<View style={styles.headerLinks}>
<Link href="/search" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">Search</ThemedText>
</Pressable>
</Link>
<Link href="/courses" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">Courses</ThemedText>
</Pressable>
</Link>
<Link href="/settings" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary"> Settings</ThemedText>
</Pressable>
</Link>
</View>
</View>
<View style={styles.captureRow}>
<Link href="/record" asChild>
<Pressable
disabled={busy}
style={({ pressed }) => [
styles.captureBtn,
{ backgroundColor: '#e5484d', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.newButtonText}>🎙 Record</ThemedText>
</Pressable>
</Link>
<Pressable
onPress={onNew}
disabled={busy}
style={({ pressed }) => [
styles.captureBtn,
{ backgroundColor: '#3c87f7', opacity: busy ? 0.5 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.newButtonText}> Import file</ThemedText>
</Pressable>
</View>
{busy && <ActiveJob />}
{job.status === 'error' && (
<ThemedView type="backgroundElement" style={styles.card}>
<ThemedText type="smallBold">Transcription failed</ThemedText>
<ThemedText type="small" themeColor="textSecondary">{job.error}</ThemedText>
</ThemedView>
)}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterBar}>
<FilterChip label="All" active={courseFilter === 'all'} onPress={() => void setCourseFilter('all')} />
<FilterChip label="Unsorted" active={courseFilter === null} onPress={() => void setCourseFilter(null)} />
{courses.map((c) => (
<FilterChip key={c.id} label={c.name} active={courseFilter === c.id} onPress={() => void setCourseFilter(c.id)} />
))}
</ScrollView>
<TextInput
value={query}
onChangeText={(t) => void setQuery(t)}
placeholder="Search transcripts…"
placeholderTextColor={theme.textSecondary}
style={[styles.search, { color: theme.text, backgroundColor: theme.backgroundElement }]}
/>
{loading && items.length === 0 ? (
<ActivityIndicator style={styles.pad} />
) : items.length === 0 ? (
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}>
{query ? 'No matches.' : 'No transcripts here yet. Pick an audio or video file to begin.'}
</ThemedText>
) : (
items.map((t) => (
<TranscriptRow
key={t.id}
item={t}
courseName={courseName(t.courseId)}
onOpen={() => router.push({ pathname: '/transcript/[id]', params: { id: t.id } })}
onDelete={() => void remove(t.id)}
/>
))
)}
</ScrollView>
<PreCaptureSheet
visible={picked !== null}
defaultTitle={picked?.name ?? 'Recording'}
onStart={(r) => void onStart(r)}
onCancel={() => setPicked(null)}
/>
</ThemedView>
);
}
function FilterChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
const theme = useTheme();
return (
<Pressable
onPress={onPress}
style={[styles.filterChip, { backgroundColor: active ? '#3c87f7' : theme.backgroundElement }]}>
<ThemedText type="small" style={active ? styles.chipActive : undefined}>{label}</ThemedText>
</Pressable>
);
}
function ActiveJob() {
const { stage, progress, partial, cancel } = useTranscribe();
const pct = Math.round(progress * 100);
return (
<ThemedView type="backgroundElement" style={styles.card}>
<View style={styles.rowBetween}>
<ThemedText type="smallBold">
{stage === 'loading' ? 'Loading model…' : 'Transcribing…'} {pct}%
</ThemedText>
<Pressable onPress={cancel} hitSlop={8}>
<ThemedText type="small" themeColor="textSecondary">Cancel</ThemedText>
</Pressable>
</View>
<ProgressBar value={progress} />
{partial.length > 0 && (
<ThemedText type="small" themeColor="textSecondary" numberOfLines={3}>
{partial.map((s) => s.text).join(' ')}
</ThemedText>
)}
</ThemedView>
);
}
function ProgressBar({ value }: { value: number }) {
return (
<View style={styles.track}>
<View style={[styles.bar, { width: `${Math.max(2, Math.min(100, value * 100))}%` }]} />
</View>
);
}
function TranscriptRow({
item,
courseName,
onOpen,
onDelete,
}: {
item: TranscriptMeta;
courseName: string | null;
onOpen: () => void;
onDelete: () => void;
}) {
const date = new Date(item.lectureDate ?? item.createdAt).toLocaleDateString();
return (
<Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}>
<ThemedView type="backgroundElement" style={styles.card}>
<View style={styles.rowBetween}>
<ThemedText type="smallBold" numberOfLines={1} style={styles.flex}>
{item.title}
</ThemedText>
<Pressable onPress={onDelete} hitSlop={8}>
<ThemedText type="small" themeColor="textSecondary"></ThemedText>
</Pressable>
</View>
<ThemedText type="small" themeColor="textSecondary">
{courseName ? `${courseName} · ` : ''}
{date} · {formatClock(item.durationSec)} · {item.segmentCount} segments
</ThemedText>
</ThemedView>
</Pressable>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
content: {
padding: Spacing.three,
gap: Spacing.three,
maxWidth: MaxContentWidth,
width: '100%',
alignSelf: 'center',
},
flex: { flex: 1 },
headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two },
headerLinks: { flexDirection: 'row', gap: Spacing.three, alignItems: 'center' },
newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
captureRow: { flexDirection: 'row', gap: Spacing.two },
captureBtn: { flex: 1, paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three },
filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 },
chipActive: { color: '#fff', fontWeight: '700' },
search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' },
bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' },
pad: { paddingVertical: Spacing.four, textAlign: 'center' },
pressed: { opacity: 0.7 },
});