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
242 lines
7.7 KiB
TypeScript
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 },
|
|
});
|
|
}
|