Files
wisp/src/app/(tabs)/index.tsx
T
NilsBriggen fa12817050
CI / test (push) Successful in 19s
CI / deploy-web (push) Successful in 35s
CI / build-apk (push) Successful in 6m43s
Transcription progress UX + use WASM on mobile web
Two issues behind "it just shows downloaded 100% / loads forever / crashes
after a couple seconds":

1. Progress was invisible during transcription. The 'transcribing' stage
   only fired AFTER the first (slow) chunk, so the UI sat at "Loading
   model… 100%" through the entire first inference and looked frozen.
   - pipeline now emits a transcribing kickoff (progress 0) BEFORE the
     first chunk and carries chunkIndex/chunkCount on every event.
   - transcribeStore threads those through; the Library job card shows a
     spinner, "Transcribing… N%", and "part i of N" so a long file's
     progress is legible and obviously advancing.

2. Web crash on mobile. We picked WebGPU + fp16 whenever navigator.gpu
   existed, but mobile WebGPU drivers crash on sustained fp16 Whisper
   inference (transcribes a chunk, then the GPU process dies). Mobile web
   now uses cross-origin-isolated multi-threaded WASM (slower but stable);
   desktop keeps WebGPU.

Native's "loads forever" was the same missing-feedback problem — it was
grinding through chunks with no UI signal; the chunk counter now shows it.

tsc clean, 279 tests pass (pipeline progress test updated for the kickoff
event), web export OK.

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

265 lines
9.8 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 { pickAudio, type PickedAudio } from '@/lib/pickAudio';
import { useCourses } from '@/stores/coursesStore';
import { useTranscribe } from '@/stores/transcribeStore';
import { useTranscripts } 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);
}, [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.subHeader}>
<ThemedText type="small" themeColor="textSecondary" style={styles.flex}>
On your device nothing uploaded.
</ThemedText>
<Link href="/courses" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary">Courses </ThemedText>
</Pressable>
</Link>
</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.captureText}>🎙 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.captureText}> Import</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>
)}
{courses.length > 0 && (
<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="Filter by title…"
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 lectures yet. Record one or import an audio/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, chunkIndex, chunkCount, cancel } = useTranscribe();
const pct = Math.round(progress * 100);
const transcribing = stage === 'transcribing';
// While transcribing, show "chunk i/N" so a long file's progress is legible
// and it's obvious work is happening between the (discrete) per-chunk updates.
const label = stage === 'loading' ? `Loading model… ${pct}%` : `Transcribing… ${pct}%`;
const sub =
transcribing && chunkCount
? `part ${Math.min((chunkIndex ?? 0) + (pct < 100 ? 1 : 0), chunkCount)} of ${chunkCount}`
: undefined;
return (
<ThemedView type="backgroundElement" style={styles.card}>
<View style={styles.rowBetween}>
<View style={styles.jobTitleRow}>
<ActivityIndicator size="small" />
<ThemedText type="smallBold">{label}</ThemedText>
</View>
<Pressable onPress={cancel} hitSlop={8}>
<ThemedText type="small" themeColor="textSecondary">Cancel</ThemedText>
</Pressable>
</View>
<ProgressBar value={progress} />
{sub && (
<ThemedText type="small" themeColor="textSecondary">{sub}</ThemedText>
)}
{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',
paddingBottom: Spacing.six,
},
flex: { flex: 1 },
subHeader: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two },
captureRow: { flexDirection: 'row', gap: Spacing.two },
captureBtn: { flex: 1, paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
captureText: { color: '#fff', fontWeight: '700', fontSize: 16 },
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
jobTitleRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, flex: 1 },
filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three },
filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: 999 },
chipActive: { color: '#fff', fontWeight: '700' },
search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.three, 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 },
});