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(null); const [sessions, setSessions] = useState([]); const [projects, setProjects] = useState([]); const [syncing, setSyncing] = useState(false); const [error, setError] = useState(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 ( syncing ? ( ) : access === "limited" ? ( void chooseItems()} > ) : null, }} /> (row.kind === "project" ? row.project.id : row.collection.id)} contentContainerStyle={styles.content} contentInsetAdjustmentBehavior="automatic" stickySectionHeadersEnabled={false} refreshControl={ void refresh(access)} /> } ListHeaderComponent={ needsAccess ? ( void allow()} > {access === "blocked" ? t("Open Settings") : t("Allow photo access")} ) : error ? ( {error} ) : null } renderSectionHeader={({ section }) => // A lone sessions list needs no heading. sections.length > 1 ? {section.title} : null } renderItem={({ item }) => item.kind === "project" ? ( [styles.row, pressed && styles.rowPressed]} onPress={() => router.push({ pathname: "/media-project", params: { projectId: item.project.id } }) } > {item.project.title} ) : ( [styles.row, pressed && styles.rowPressed]} onPress={() => router.push({ pathname: "/media-session", params: { collectionId: item.collection.id }, }) } > {item.collection.title} {item.collection.videoCount > 0 ? `${t("{count} items", { count: item.collection.assetCount })} ยท ${formatDuration( item.collection.totalDurationMs, )}` : t("{count} items", { count: item.collection.assetCount })} ) } renderSectionFooter={({ section }) => section.data.length === 0 && !syncing && !needsAccess ? ( {t("No sessions yet")} ) : null } /> ); } 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 }, }); }