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
190 lines
6.2 KiB
TypeScript
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 },
|
|
});
|
|
}
|