import type { MediaAsset, MediaCapabilities, MediaRender, MediaSource, MediaUpload, } from "@rakazo/contracts"; import { fetch as expoFetch } from "expo/fetch"; import * as Application from "expo-application"; import { File, Paths } from "expo-file-system"; import { AssetField, getPermissionsAsync, Asset as LibraryAsset, MediaType, presentPermissionsPicker, Query, requestPermissionsAsync, } from "expo-media-library"; import * as Notifications from "expo-notifications"; import { useRouter } from "expo-router"; import * as SecureStore from "expo-secure-store"; import * as Sharing from "expo-sharing"; import { useEffect, useState } from "react"; import { Platform } from "react-native"; import { authHeaders, currentApiBase, rpc, selectedSpaceId } from "./api"; import { t } from "./i18n"; import type { DeviceAsset, PushInput, PushResult } from "./media-index"; import { syncDeviceIndex } from "./media-index"; import type { LibraryAccess } from "./media-project"; import { libraryAccess, mediaNotificationRoute, renderFileUrl, shareFileName, uploadsFromDevice, videoOutput, } from "./media-project"; import type { ChunkBytes, UploadTransport } from "./media-upload"; import { combinedProgress, hashOriginal, MediaUploadHttpError, uploadOriginal, } from "./media-upload"; /* * Native glue for Media Studio on mobile: RPC calls, the media library, the * chunk route, sharing, and push routing. Decisions live in the pure * media-index, media-upload, and media-project modules. */ export function mediaRpc( proc: string, body: unknown = {}, options?: { timeoutMs?: number | null; signal?: AbortSignal }, ): Promise { return rpc(`media/${proc}`, body, options); } /** Media appears only when the server reports Media Studio configured. */ export function useMediaAvailable(): boolean { const [available, setAvailable] = useState(false); useEffect(() => { let active = true; void mediaRpc("capabilities") .then((capabilities) => { if (active) setAvailable(capabilities.configured); }) .catch(() => { if (active) setAvailable(false); }); return () => { active = false; }; }, []); return available; } // --------------------------------------------------------------------------- // Library permission // --------------------------------------------------------------------------- const LIBRARY_PERMISSIONS = ["photo", "video"] as const; export async function readLibraryAccess(): Promise { return libraryAccess(await getPermissionsAsync(false, [...LIBRARY_PERMISSIONS])); } export async function requestLibraryAccess(): Promise { return libraryAccess(await requestPermissionsAsync(false, [...LIBRARY_PERMISSIONS])); } /** With limited access, let the user change which items the app may see. */ export async function changeLimitedSelection(): Promise { await presentPermissionsPicker(["photo", "video"]); } // --------------------------------------------------------------------------- // Device index // --------------------------------------------------------------------------- async function deviceId(): Promise { if (Platform.OS === "ios") { const id = await Application.getIosIdForVendorAsync(); if (id) return `ios:${id}`; } else if (Platform.OS === "android") { return `android:${Application.getAndroidId()}`; } throw new Error(t("Failed")); } function storeKey(sourceId: string): string { const scope = `${selectedSpaceId() ?? "default"}.${sourceId}`.replace(/[^A-Za-z0-9._-]/g, "_"); return `rakazo.media.sync.${scope}`; } async function readSyncToken(sourceId: string): Promise { try { return (await SecureStore.getItemAsync(storeKey(sourceId))) || null; } catch { return null; } } async function writeSyncToken(sourceId: string, token: string | null): Promise { try { if (token) await SecureStore.setItemAsync(storeKey(sourceId), token); } catch { // The next sync then pushes everything again; the server reports it unchanged. } } export async function registerDeviceSource(): Promise { return mediaRpc("sources/registerClient", { kind: "ios_library", label: Platform.OS === "ios" ? "iPhone" : "Android", deviceId: await deviceId(), }); } async function listDevice(offset: number, limit: number): Promise { const rows = await new Query() .within(AssetField.MEDIA_TYPE, [MediaType.IMAGE, MediaType.VIDEO]) .orderBy({ key: AssetField.CREATION_TIME, ascending: true }) .offset(offset) .limit(limit) .exeForMetadata(); return rows.map((row) => ({ id: row.id, filename: row.filename, mediaType: row.mediaType, width: row.width, height: row.height, duration: row.duration, creationTime: row.creationTime, modificationTime: row.modificationTime, isFavorite: row.isFavorite, })); } async function serverExternalIds(sourceId: string): Promise { const ids: string[] = []; let cursor: string | null = null; do { const page: { items: MediaAsset[]; nextCursor: string | null } = await mediaRpc( "assets/search", { sourceId, cursor, limit: 500 }, ); for (const asset of page.items) ids.push(asset.externalId); cursor = page.nextCursor; } while (cursor); return ids; } let syncing: Promise | null = null; /** Index the device library into its source. One sync runs at a time. */ export function syncDeviceLibrary(): Promise { syncing ??= (async () => { const source = await registerDeviceSource(); const result = await syncDeviceIndex({ sourceId: source.id, syncToken: await readSyncToken(source.id), io: { listDevice, locate: (id) => new LibraryAsset(id).getLocation(), push: (input: PushInput) => mediaRpc("sources/pushItems", input, { timeoutMs: 60_000 }), serverAssetCount: async () => { const sources = await mediaRpc("sources/list"); return sources.find((entry) => entry.id === source.id)?.assetCount ?? 0; }, serverExternalIds: () => serverExternalIds(source.id), }, }); await writeSyncToken(source.id, result.syncToken); return source; })().finally(() => { syncing = null; }); return syncing; } // --------------------------------------------------------------------------- // Uploads // --------------------------------------------------------------------------- async function rpcUploadCall(proc: string, body: unknown, timeoutMs?: number): Promise { try { return await mediaRpc(proc, body, timeoutMs ? { timeoutMs } : undefined); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/expired/i.test(message)) throw new MediaUploadHttpError(409, message); throw error; } } async function putChunk( uploadId: string, index: number, bytes: ChunkBytes, signal?: AbortSignal, ): Promise { const url = `${currentApiBase()}/api/media/uploads/${encodeURIComponent(uploadId)}/chunks/${index}`; const response = await expoFetch(url, { method: "PUT", headers: { ...(await authHeaders()), origin: "rakazo://", "content-type": "application/octet-stream", }, body: bytes, signal, }); const body = (await response.json().catch(() => null)) as | (MediaUpload & { error?: undefined }) | { error?: string } | null; if (!response.ok) { const message = (body && "error" in body && body.error) || `Upload failed (${response.status})`; throw new MediaUploadHttpError(response.status, message); } return body as MediaUpload; } const transport: UploadTransport = { begin: (input) => rpcUploadCall("uploads/begin", input), status: (uploadId) => rpcUploadCall("uploads/status", { uploadId }), putChunk, complete: (uploadId) => rpcUploadCall("uploads/complete", { uploadId }, 10 * 60_000), }; /** Hashes are slow on large videos; keep them for retries within this session. */ const hashes = new Map(); async function uploadOne( asset: MediaAsset, onProgress: (fraction: number) => void, signal?: AbortSignal, ): Promise { const original = new LibraryAsset(asset.externalId); const file = new File(await original.getUri()); const sizeBytes = file.size; if (!sizeBytes) throw new Error("Original not available"); const fileName = (await original.getFilename()) || asset.externalId; const handle = file.open(); try { const read = (start: number, length: number) => { handle.offset = start; return handle.readBytes(length); }; const key = `${asset.id}:${sizeBytes}`; let sha256 = hashes.get(key); if (!sha256) { sha256 = await hashOriginal(sizeBytes, read, { signal, onProgress: (fraction) => onProgress(fraction * 0.1), }); hashes.set(key, sha256); } await uploadOriginal({ assetId: asset.id, fileName, sizeBytes, sha256, read, transport, signal, onProgress: (fraction) => onProgress(0.1 + fraction * 0.9), }); } finally { handle.close(); } } /** Upload every listed item that still waits for its original. Returns the ids that failed. */ export async function uploadAssets( assets: MediaAsset[], deviceSourceId: string, onProgress: (fraction: number) => void, signal?: AbortSignal, ): Promise { const pending = uploadsFromDevice(assets, deviceSourceId); const progress = pending.map(() => ({ sizeBytes: 1, fraction: 0 })); const failed: string[] = []; for (const [index, asset] of pending.entries()) { signal?.throwIfAborted(); try { await uploadOne( asset, (fraction) => { const entry = progress[index]; if (entry) entry.fraction = fraction; onProgress(combinedProgress(progress)); }, signal, ); } catch (error) { if (signal?.aborted) throw error; failed.push(asset.id); } const entry = progress[index]; if (entry) entry.fraction = 1; onProgress(combinedProgress(progress)); } return failed; } // --------------------------------------------------------------------------- // Playback and sharing // --------------------------------------------------------------------------- /** Authenticated source for expo-video. */ export async function renderVideoSource( renderId: string, fileName: string, ): Promise<{ uri: string; headers: Record }> { return { uri: renderFileUrl(currentApiBase(), renderId, fileName), headers: { ...(await authHeaders()), origin: "rakazo://" }, }; } /** Download a final render and open the native share sheet. */ export async function shareRender(render: MediaRender): Promise { const video = videoOutput(render); if (!video || render.status !== "done") return; const target = new File(Paths.cache, shareFileName(render.id, video.fileName)); const file = await File.downloadFileAsync( renderFileUrl(currentApiBase(), render.id, video.fileName, true), target, { headers: { ...(await authHeaders()), origin: "rakazo://" }, idempotent: true }, ); await Sharing.shareAsync(file.uri, { mimeType: "video/mp4" }); } // --------------------------------------------------------------------------- // Push // --------------------------------------------------------------------------- /** Open the project when the user taps a render notification. Mount once in the root layout. */ export function useMediaNotificationRouting(): void { const router = useRouter(); useEffect(() => { const open = (response: Notifications.NotificationResponse | null) => { const route = mediaNotificationRoute(response?.notification.request.content.data); if (route) router.push(route); }; open(Notifications.getLastNotificationResponse()); const subscription = Notifications.addNotificationResponseReceivedListener(open); return () => subscription.remove(); }, [router]); }