fa12817050
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>
265 lines
9.8 KiB
TypeScript
265 lines
9.8 KiB
TypeScript
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 },
|
||
});
|