evOo-local/apps/mobile/app/media-session.tsx
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

190 lines
6.2 KiB
TypeScript

import type {
MediaAsset,
MediaCollection,
MediaProject,
PlatformProfileSummary,
} from "@rakazo/contracts";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
FlatList,
Image,
Pressable,
StyleSheet,
Text,
useWindowDimensions,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { NativeSymbol } from "../components/native-symbol";
import { authHeaders, currentApiBase } from "../lib/api";
import { mobileTokens } from "../lib/appearance";
import { useI18n } from "../lib/i18n";
import { mediaRpc } from "../lib/media-api";
import { assetThumbUrl, formatDuration } from "../lib/media-project";
import { presentMessageActionSheet } from "../lib/message-action-sheet";
import { native, useResolvedAppearance, useThemedStyles } from "../lib/native";
const COLUMNS = 3;
const GAP = 2;
export default function MediaSession() {
const styles = useThemedStyles(createStyles);
const { t } = useI18n();
const router = useRouter();
const appearance = useResolvedAppearance();
const { width } = useWindowDimensions();
const { collectionId } = useLocalSearchParams<{ collectionId: string }>();
const [collection, setCollection] = useState<MediaCollection | null>(null);
const [assets, setAssets] = useState<MediaAsset[]>([]);
const [headers, setHeaders] = useState<Record<string, string>>({});
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const tile = (width - GAP * (COLUMNS - 1)) / COLUMNS;
useEffect(() => {
if (!collectionId) return;
void authHeaders().then(setHeaders);
void mediaRpc<{ collection: MediaCollection; assets: MediaAsset[] }>("collections/get", {
collectionId,
})
.then((result) => {
setCollection(result.collection);
setAssets(result.assets);
})
.catch((reason) => setError(reason instanceof Error ? reason.message : String(reason)));
}, [collectionId]);
async function create(profileId: string) {
if (!collection) return;
setCreating(true);
try {
const project = await mediaRpc<MediaProject>("projects/create", {
title: collection.title.slice(0, 120) || t("Media"),
goal: "teaser",
collectionIds: [collection.id],
profileIds: [profileId],
});
router.replace({
pathname: "/media-project",
params: { projectId: project.id, upload: "1" },
});
} catch (reason) {
Alert.alert(reason instanceof Error ? reason.message : String(reason));
} finally {
setCreating(false);
}
}
async function chooseProfile() {
try {
const profiles = await mediaRpc<PlatformProfileSummary[]>("profiles/list");
if (profiles.length === 1 && profiles[0]) {
await create(profiles[0].id);
return;
}
presentMessageActionSheet({
actions: profiles.map((profile) => ({
text: profile.name,
onPress: () => void create(profile.id),
})),
cancel: t("Cancel"),
more: t("More"),
colorScheme: appearance,
});
} catch (reason) {
Alert.alert(reason instanceof Error ? reason.message : String(reason));
}
}
return (
<View style={styles.screen}>
<Stack.Screen options={{ title: collection?.title ?? "" }} />
<FlatList
data={assets}
keyExtractor={(asset) => asset.id}
numColumns={COLUMNS}
columnWrapperStyle={{ gap: GAP }}
contentContainerStyle={{ gap: GAP }}
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={error ? <Text style={styles.error}>{error}</Text> : null}
renderItem={({ item }) => (
<View style={[styles.tile, { width: tile, height: tile }]}>
{item.hasThumbnail ? (
<Image
accessibilityIgnoresInvertColors
source={{ uri: assetThumbUrl(currentApiBase(), item.id), headers }}
style={StyleSheet.absoluteFill}
/>
) : (
<NativeSymbol
ios={item.kind === "video" ? "video" : "photo"}
android={item.kind === "video" ? "videocam-outline" : "image-outline"}
color={native.tertiaryLabel}
size={22}
/>
)}
{item.kind === "video" && item.durationMs ? (
<Text style={styles.duration}>{formatDuration(item.durationMs)}</Text>
) : null}
</View>
)}
/>
{collection ? (
<SafeAreaView edges={["bottom"]} style={styles.footer}>
<Pressable
accessibilityRole="button"
disabled={creating}
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => void chooseProfile()}
>
{creating ? (
<ActivityIndicator color={mobileTokens().primaryForeground} />
) : (
<Text style={styles.primaryLabel}>{t("Upload and create project")}</Text>
)}
</Pressable>
</SafeAreaView>
) : null}
</View>
);
}
function createStyles() {
const tokens = mobileTokens();
return StyleSheet.create({
screen: { flex: 1, backgroundColor: native.page },
tile: {
backgroundColor: native.fill,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
},
duration: {
position: "absolute",
right: 6,
bottom: 4,
color: tokens.foreground,
fontSize: 12,
fontWeight: "600",
fontVariant: ["tabular-nums"],
textShadowColor: tokens.background,
textShadowRadius: 3,
},
footer: { paddingHorizontal: 16, paddingTop: 8, backgroundColor: native.page },
primaryButton: {
minHeight: 50,
borderRadius: 14,
backgroundColor: tokens.primary,
alignItems: "center",
justifyContent: "center",
marginBottom: 8,
},
pressed: { opacity: 0.8 },
primaryLabel: { color: tokens.primaryForeground, fontSize: 16, fontWeight: "600" },
error: { color: tokens.destructive, fontSize: 14, padding: 16 },
});
}