import type { MediaAsset, MediaCapabilities, MediaCollection, MediaEditPlan, MediaProject, MediaRender, PlatformProfileSummary, } from "@rakazo/contracts"; import { Stack, useLocalSearchParams } from "expo-router"; import { useVideoPlayer, VideoView } from "expo-video"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native"; import { mobileTokens } from "../lib/appearance"; import { useI18n } from "../lib/i18n"; import { mediaRpc, registerDeviceSource, renderVideoSource, shareRender, uploadAssets, } from "../lib/media-api"; import type { MediaVariant } from "../lib/media-project"; import { blockers, canApprove, canShare, MEDIA_POLL_MS, projectReadiness, projectVariants, shouldPollProject, variantState, videoOutput, } from "../lib/media-project"; import { native, useThemedStyles } from "../lib/native"; type ProjectData = { project: MediaProject; plans: MediaEditPlan[]; renders: MediaRender[] }; function message(reason: unknown): string { return reason instanceof Error ? reason.message : String(reason); } export default function MediaProjectScreen() { const styles = useThemedStyles(createStyles); const { t } = useI18n(); const { projectId, upload } = useLocalSearchParams<{ projectId: string; upload?: string }>(); const [data, setData] = useState(null); const [assets, setAssets] = useState([]); const [profiles, setProfiles] = useState([]); const [analysis, setAnalysis] = useState(true); const [deviceSourceId, setDeviceSourceId] = useState(null); const [uploadProgress, setUploadProgress] = useState(null); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const uploadStarted = useRef(false); const planned = useRef(false); const uploadAbort = useRef(null); const load = useCallback(async () => { if (!projectId) return; const next = await mediaRpc("projects/get", { projectId }); const collections = await Promise.all( next.project.collectionIds.map((collectionId) => mediaRpc<{ collection: MediaCollection; assets: MediaAsset[] }>("collections/get", { collectionId, }), ), ); const byId = new Map(); for (const entry of collections) for (const asset of entry.assets) byId.set(asset.id, asset); setAssets([...byId.values()]); setData(next); }, [projectId]); useEffect(() => { void load().catch((reason) => setError(message(reason))); void mediaRpc("profiles/list") .then(setProfiles) .catch(() => undefined); void registerDeviceSource() .then((source) => setDeviceSourceId(source.id)) .catch(() => undefined); void mediaRpc("capabilities") .then((capabilities) => setAnalysis(capabilities.configured)) .catch(() => undefined); return () => uploadAbort.current?.abort(); }, [load]); const readiness = projectReadiness(assets, analysis, deviceSourceId); const uploading = uploadProgress !== null; const startUpload = useCallback(async () => { if (!deviceSourceId) return; uploadStarted.current = true; const controller = new AbortController(); uploadAbort.current = controller; setUploadProgress(0); try { const failed = await uploadAssets( assets, deviceSourceId, setUploadProgress, controller.signal, ); if (failed.length > 0) { Alert.alert(t("{count} items could not be uploaded.", { count: failed.length })); } } catch (reason) { if (!controller.signal.aborted) setError(message(reason)); } finally { if (!controller.signal.aborted) { setUploadProgress(null); await load().catch(() => undefined); } } }, [assets, deviceSourceId, load, t]); useEffect(() => { if (upload === "1" && readiness === "uploading" && !uploadStarted.current) { void startUpload(); } }, [upload, readiness, startUpload]); useEffect(() => { if (!data || readiness !== "ready" || data.plans.length > 0 || planned.current) return; planned.current = true; void (async () => { try { const plans = await mediaRpc( "projects/autoPlan", { projectId: data.project.id }, { timeoutMs: 120_000 }, ); for (const plan of plans) { await mediaRpc("renders/start", { planId: plan.id, quality: "draft" }); } await load(); } catch (reason) { setError(message(reason)); } })(); }, [data, readiness, load]); useEffect(() => { if (!data || uploading) return; const waiting = data.plans.length === 0 && readiness === "processing"; if (!waiting && !shouldPollProject(data.renders)) return; const timer = setTimeout(() => void load().catch(() => undefined), MEDIA_POLL_MS); return () => clearTimeout(timer); }, [data, readiness, uploading, load]); async function act(variant: MediaVariant, action: () => Promise) { setBusy(variant.profileId); try { await action(); await load(); } catch (reason) { Alert.alert(message(reason)); } finally { setBusy(null); } } const variants = data ? projectVariants({ profileIds: data.project.profileIds, plans: data.plans, renders: data.renders, profiles, }) : []; return ( {error ? {error} : null} {!data ? : null} {data && readiness === "uploading" ? ( uploading ? ( {t("Uploading {percent}%", { percent: Math.round((uploadProgress ?? 0) * 100) })} ) : ( void startUpload()} > {t("Upload")} ) ) : null} {data && variants.length === 0 && readiness === "processing" ? ( ) : null} {variants.map((variant) => ( void act(variant, () => mediaRpc("renders/start", { planId: variant.plan.id, quality: "final" }), ) } onShare={() => void act(variant, async () => { if (variant.final) await shareRender(variant.final); }) } /> ))} {variants.length > 0 ? {t("Edit on web or desktop.")} : null} ); } function VariantCard({ variant, busy, onApprove, onShare, }: { variant: MediaVariant; busy: boolean; onApprove: () => void; onShare: () => void; }) { const styles = useThemedStyles(createStyles); const { t } = useI18n(); const state = variantState(variant); const progress = Math.round((variant.latest?.progress ?? 0) * 100); const blocker = blockers(variant.latest)[0]; const status = state === "rendering" || state === "approved_rendering" ? t("Rendering {percent}%", { percent: progress }) : state === "blocked" ? (blocker?.message ?? t("Blocked")) : state === "failed" ? (variant.latest?.error ?? t("Failed")) : null; return ( {variant.profileName} {variant.preview ? : null} {status ? ( {status} ) : null} {busy ? ( ) : canApprove(variant) || canShare(variant) ? ( {canApprove(variant) ? ( {t("Approve")} ) : null} {canShare(variant) ? ( {t("Share")} ) : null} ) : null} ); } function VariantPlayer({ render }: { render: MediaRender }) { const renderId = render.id; const fileName = videoOutput(render)?.fileName ?? null; const [source, setSource] = useState<{ uri: string; headers: Record } | null>( null, ); useEffect(() => { if (!fileName) return; let active = true; void renderVideoSource(renderId, fileName).then((next) => { if (active) setSource(next); }); return () => { active = false; }; }, [renderId, fileName]); return source ? : null; } function Player({ source }: { source: { uri: string; headers: Record } }) { const styles = useThemedStyles(createStyles); const player = useVideoPlayer(source); return ; } function createStyles() { const tokens = mobileTokens(); return StyleSheet.create({ screen: { flex: 1, backgroundColor: native.page }, content: { padding: 16, gap: 12 }, card: { padding: 14, borderRadius: 16, backgroundColor: native.fill, gap: 10 }, cardTitle: { color: native.label, fontSize: 16, fontWeight: "600" }, video: { width: "100%", aspectRatio: 9 / 16, maxHeight: 460, borderRadius: 12 }, status: { color: native.secondaryLabel, fontSize: 14 }, blocked: { color: tokens.destructive, fontSize: 14 }, progressRow: { flexDirection: "row", alignItems: "center", gap: 10 }, actions: { flexDirection: "row", gap: 8 }, button: { minHeight: 42, paddingHorizontal: 16, borderRadius: 12, backgroundColor: native.fillPressed, alignItems: "center", justifyContent: "center", }, buttonLabel: { color: native.label, fontSize: 15, fontWeight: "600" }, primaryButton: { minHeight: 48, borderRadius: 12, backgroundColor: tokens.primary, alignItems: "center", justifyContent: "center", }, primaryLabel: { color: tokens.primaryForeground, fontSize: 16, fontWeight: "600" }, error: { color: tokens.destructive, fontSize: 14 }, note: { color: native.tertiaryLabel, fontSize: 13, textAlign: "center", marginTop: 4 }, }); }