Claude 4c30af9ea3
feat(media): mobile library index, resumable upload, and project variants
Index photos and videos from the device library into a client-pushed
ios_library source, upload originals in resumable chunks, and review,
approve, and share project variants with expo-video and the native share
sheet. Pure logic for chunking, resume, indexing, and variants is unit
tested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:20:42 +00:00

242 lines
7.7 KiB
TypeScript

import type { MediaCollection, MediaProject } from "@rakazo/contracts";
import { Stack, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Linking,
Pressable,
RefreshControl,
SectionList,
StyleSheet,
Text,
View,
} from "react-native";
import { NativeSymbol } from "../components/native-symbol";
import { mobileTokens } from "../lib/appearance";
import { useI18n } from "../lib/i18n";
import {
changeLimitedSelection,
mediaRpc,
readLibraryAccess,
requestLibraryAccess,
syncDeviceLibrary,
} from "../lib/media-api";
import type { LibraryAccess } from "../lib/media-project";
import { formatDuration } from "../lib/media-project";
import { native, useThemedStyles } from "../lib/native";
type Row =
| { kind: "project"; project: MediaProject }
| { kind: "session"; collection: MediaCollection };
export default function MediaLibrary() {
const styles = useThemedStyles(createStyles);
const { t } = useI18n();
const router = useRouter();
const [access, setAccess] = useState<LibraryAccess | null>(null);
const [sessions, setSessions] = useState<MediaCollection[]>([]);
const [projects, setProjects] = useState<MediaProject[]>([]);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadLists = useCallback(async () => {
const [sessionPage, projectPage] = await Promise.all([
mediaRpc<{ items: MediaCollection[] }>("collections/list", { kind: "session", limit: 200 }),
mediaRpc<{ items: MediaProject[] }>("projects/list", { limit: 50 }),
]);
setSessions(sessionPage.items);
setProjects(projectPage.items.filter((project) => project.status !== "archived"));
}, []);
const refresh = useCallback(
async (current: LibraryAccess | null) => {
setError(null);
try {
if (current === "all" || current === "limited") {
setSyncing(true);
await syncDeviceLibrary();
}
await loadLists();
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
} finally {
setSyncing(false);
}
},
[loadLists],
);
useEffect(() => {
void readLibraryAccess()
.catch(() => "denied" as const)
.then((current) => {
setAccess(current);
return refresh(current);
});
}, [refresh]);
async function allow() {
if (access === "blocked") {
await Linking.openSettings();
return;
}
const next = await requestLibraryAccess();
setAccess(next);
await refresh(next);
}
async function chooseItems() {
await changeLimitedSelection().catch(() => undefined);
await refresh(access);
}
const sections = [
...(projects.length
? [
{
title: t("Projects"),
data: projects.map((project): Row => ({ kind: "project", project })),
},
]
: []),
{
title: t("Sessions"),
data: sessions.map((collection): Row => ({ kind: "session", collection })),
},
];
const needsAccess = access !== null && access !== "all" && access !== "limited";
return (
<View style={styles.screen}>
<Stack.Screen
options={{
title: t("Media"),
headerRight: () =>
syncing ? (
<ActivityIndicator color={native.secondaryLabel} />
) : access === "limited" ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Choose photos and videos")}
hitSlop={10}
onPress={() => void chooseItems()}
>
<NativeSymbol ios="photo.badge.plus" android="images-outline" />
</Pressable>
) : null,
}}
/>
<SectionList
sections={sections}
keyExtractor={(row) => (row.kind === "project" ? row.project.id : row.collection.id)}
contentContainerStyle={styles.content}
contentInsetAdjustmentBehavior="automatic"
stickySectionHeadersEnabled={false}
refreshControl={
<RefreshControl refreshing={false} onRefresh={() => void refresh(access)} />
}
ListHeaderComponent={
needsAccess ? (
<Pressable
accessibilityRole="button"
style={styles.primaryButton}
onPress={() => void allow()}
>
<Text style={styles.primaryLabel}>
{access === "blocked" ? t("Open Settings") : t("Allow photo access")}
</Text>
</Pressable>
) : error ? (
<Text style={styles.error}>{error}</Text>
) : null
}
renderSectionHeader={({ section }) =>
// A lone sessions list needs no heading.
sections.length > 1 ? <Text style={styles.sectionTitle}>{section.title}</Text> : null
}
renderItem={({ item }) =>
item.kind === "project" ? (
<Pressable
accessibilityRole="button"
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() =>
router.push({ pathname: "/media-project", params: { projectId: item.project.id } })
}
>
<Text style={styles.rowTitle} numberOfLines={1}>
{item.project.title}
</Text>
</Pressable>
) : (
<Pressable
accessibilityRole="button"
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() =>
router.push({
pathname: "/media-session",
params: { collectionId: item.collection.id },
})
}
>
<Text style={styles.rowTitle} numberOfLines={1}>
{item.collection.title}
</Text>
<Text style={styles.rowDetail}>
{item.collection.videoCount > 0
? `${t("{count} items", { count: item.collection.assetCount })} · ${formatDuration(
item.collection.totalDurationMs,
)}`
: t("{count} items", { count: item.collection.assetCount })}
</Text>
</Pressable>
)
}
renderSectionFooter={({ section }) =>
section.data.length === 0 && !syncing && !needsAccess ? (
<Text style={styles.empty}>{t("No sessions yet")}</Text>
) : null
}
/>
</View>
);
}
function createStyles() {
const tokens = mobileTokens();
return StyleSheet.create({
screen: { flex: 1, backgroundColor: native.page },
content: { padding: 16, gap: 8 },
sectionTitle: {
color: native.secondaryLabel,
fontSize: 13,
fontWeight: "600",
marginTop: 12,
marginBottom: 6,
},
row: {
minHeight: 56,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
backgroundColor: native.fill,
justifyContent: "center",
gap: 2,
marginBottom: 8,
},
rowPressed: { backgroundColor: native.fillPressed },
rowTitle: { color: native.label, fontSize: 16, fontWeight: "500" },
rowDetail: { color: native.secondaryLabel, fontSize: 13 },
primaryButton: {
minHeight: 48,
borderRadius: 12,
backgroundColor: tokens.primary,
alignItems: "center",
justifyContent: "center",
marginBottom: 8,
},
primaryLabel: { color: tokens.primaryForeground, fontSize: 16, fontWeight: "600" },
error: { color: tokens.destructive, fontSize: 14, marginBottom: 8 },
empty: { color: native.tertiaryLabel, fontSize: 14, paddingVertical: 8 },
});
}