Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
83 kB · 2398 lines
TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399import { useNavigation, useRoute } from "@react-navigation/native";import { Admonition, Button, Checkbox, hexToRgba, MenuContainer, MenuGroup, MenuLabel, MenuSeparator, SegmentedTabs, Select, Text, Tooltip, useBetaStatus, useTheme, View, zero,} from "@streamplace/components";import { CONTENT_WARNINGS, LICENSE_OPTIONS,} from "@streamplace/components/src/lib/metadata-constants";import { fontFamilies, scrims,} from "@streamplace/components/src/lib/theme/tokens";import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc";import ActivityPicker from "components/activity-picker";import AQLink from "components/aqlink";import { EmptyState, EmptyStateTile } from "components/empty-state";import Loading from "components/loading/loading";import BetaAccessGate from "components/upload/beta-access-gate";import { Image } from "expo-image";import { AlertCircle, ArrowUp, CheckCircle2, FileVideo, Film, ImagePlus, LoaderCircle, Pencil, Radio, Sparkles, UploadCloud, Video, X,} from "lucide-react-native";import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { Animated, Platform, Pressable, ScrollView, TextInput, useWindowDimensions,} from "react-native";import { useStore } from "store";import { useIsReady, useUserProfile } from "store/hooks";import { place } from "streamplace";import * as uploadManager from "utils/upload-manager";
// ── types ────────────────────────────────────────────────────────────────────
type TrackRef = { uri: string; cid: string };
type UploadPhase = | { kind: "idle" } | { kind: "creating" } | { kind: "uploading"; pct: number; bytesSent: number; bytesTotal: number } | { kind: "processing"; uploadId: string; serverStatus?: "pending" | "processing"; progress?: number; } // With draft VODs the upload flow no longer polls or publishes: once the // bytes are uploaded the server creates a draft automatically, and the user // finishes from the Drafts tab. `ready`/`publishing` are retained for type // compatibility but are no longer reached by the upload state machine. | { kind: "ready"; uploadId: string; tracks: TrackRef[]; durationMs: number } | { kind: "publishing" } | { kind: "done" } | { kind: "error"; message: string };
// Editable metadata fields shared by the draft editor and the published-video// editor. Extracted so both flows render the same form.type MetadataState = { title: string; description: string; activity: place.stream.livestream.Main["activity"]; tags: string[]; tagInput: string; thumbnail: Blob | undefined; thumbnailUrl: string | undefined; warnings: Set<string>; license: string;};
const DEFAULT_METADATA: MetadataState = { title: "", description: "", activity: undefined, tags: [], tagInput: "", thumbnail: undefined, thumbnailUrl: undefined, warnings: new Set<string>(), license: "place.stream.metadata.contentRights#all-rights-reserved",};
const BETA_FEATURE = "vod";
// ── helpers ───────────────────────────────────────────────────────────────────
function humanBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;}
// Returns an error string if the file doesn't look like a video, null if ok.// Reads the first 12 bytes and matches magic signatures for common containers.async function validateVideoFile(file: File): Promise<string | null> { const buf = await file.slice(0, 12).arrayBuffer(); const b = new Uint8Array(buf);
const eq = (offset: number, ...bytes: number[]) => bytes.every((v, i) => b[offset + i] === v); const str = (offset: number, len: number) => String.fromCharCode(...Array.from(b.slice(offset, offset + len)));
// MP4 / MOV / M4V — "ftyp" box at offset 4 if (str(4, 4) === "ftyp") return null; // WebM / MKV — EBML magic if (eq(0, 0x1a, 0x45, 0xdf, 0xa3)) return null; // AVI — "RIFF" header with "AVI " chunk if (str(0, 4) === "RIFF" && str(8, 4) === "AVI ") return null; // OGG (video: OGV, Theora) if (str(0, 4) === "OggS") return null; // FLV if (str(0, 3) === "FLV") return null; // MPEG-TS — 188-byte packets starting with sync byte 0x47 if (b[0] === 0x47) return null;
return "File doesn't appear to be a supported video format (MP4, WebM, MKV, MOV, AVI, OGG, FLV, MPEG-TS).";}
// Extract the rkey (tid) from an at:// or ats:// URI. The tid is always the// last path segment for both draft URIs and published-video URIs.function tidFromUri(uri: string): string { return uri.split("/").pop() ?? "";}
// Build the ats:// draft URI for the authenticated user + tid.function draftUri(did: string, tid: string): string { return `ats://${did}/place.stream.vod.drafts/self/${did}/place.stream.vod.draftVideo/${tid}`;}
// Build the at:// published-video URI for the authenticated user + tid.function videoUri(did: string, tid: string): string { return `at://${did}/place.stream.video/${tid}`;}
function inputStyle(theme: any) { return { borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, padding: 8, color: theme.colors.foreground, backgroundColor: theme.colors.background, };}
// ── login / beta gate (shared by all upload routes) ──────────────────────────
function useUploadGate() { const agent = usePDSAgent(); const isReady = useIsReady(); const userProfile = useUserProfile(); const openLoginModal = useStore((state) => state.openLoginModal); const route = useRoute(); const { status: betaStatus, loading: betaLoading, markRequested, } = useBetaStatus(BETA_FEATURE);
if (!isReady) return { state: "loading" as const }; if (!userProfile) { return { state: "login" as const, openLoginModal, route, }; } if (agent?.did && betaStatus === null && betaLoading) { return { state: "loading" as const }; } if (betaStatus === "none" || betaStatus === "requested") { return { state: "beta" as const, betaStatus, markRequested, }; } return { state: "ok" as const, agent };}
function UploadGate({ children }: { children: React.ReactNode }) { const gate = useUploadGate(); const { theme } = useTheme(); if (gate.state === "loading") return <Loading />; if (gate.state === "login") { return ( <View style={[ zero.flex.values[1], zero.layout.flex.align.center, zero.layout.flex.justify.center, zero.px[6], zero.py[12], ]} > <View style={{ maxWidth: 440, width: "100%", alignItems: "center", gap: 16, }} > <Text size="2xl" weight="semibold" style={{ textAlign: "center" }}> Log in to upload videos </Text> <Text color="muted" center> You need to be logged in to upload and manage your videos. </Text> <Button onPress={() => gate.openLoginModal({ name: gate.route.name, params: gate.route.params, }) } > <Text style={{ color: theme.colors.primaryForeground, fontWeight: "600", }} > Log in </Text> </Button> </View> </View> ); } if (gate.state === "beta") { return ( <BetaAccessGate feature={BETA_FEATURE} status={gate.betaStatus} onRequested={gate.markRequested} /> ); } return <>{children}</>;}
// ── tab nav row ──────────────────────────────────────────────────────────────
// Segmented control for the upload section. Rendered inside a fixed maxWidth-960// column (self-contained) so its left edge stays put across every tab screen —// the previous version inherited each screen's content width and appeared to// jump when switching tabs.function UploadTabNav({ activeScreen }: { activeScreen?: string } = {}) { const navigation = useNavigation(); const { theme } = useTheme(); const c = theme.colors; const route = useRoute(); // Sub-routes (e.g. the Edit Video editor) aren't tabs themselves, so they pass // the tab they belong under to keep one segment lit. const current = activeScreen ?? route.name;
// Views of your content — not actions. "Upload" is a verb, so it lives as the // CTA on the right, not as a peer tab. const tabs = [ { label: "My Videos", screen: "UploadVideos" }, { label: "Livestreams", screen: "UploadLivestreams" }, { label: "Drafts", screen: "UploadDrafts" }, ] as const;
return ( <View style={{ maxWidth: 960, width: "100%", marginBottom: 20 }}> <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12, }} > <SegmentedTabs options={tabs.map((tab) => ({ label: tab.label, value: tab.screen, }))} value={current} onChange={(screen) => navigation.navigate(screen as any)} /> {/* Persistent primary action, visible on every content view. width="min" sets alignSelf:flex-start (hug content), which in this row would pin the button to the top — override back to center so it aligns with the tabs. */} <Button size="md" width="min" leftIcon={<UploadCloud size={18} />} onPress={() => navigation.navigate("Upload" as any)} style={{ alignSelf: "center" }} > Upload video </Button> </View> </View> );}
// ── shared metadata editor ───────────────────────────────────────────────────
// The metadata form (title/description/tags/activity/content-warnings/license/// thumbnail). Shared by the draft editor and the published-video editor.function MetadataEditor({ meta, setMeta,}: { meta: MetadataState; setMeta: ( updater: (prev: MetadataState) => MetadataState | Partial<MetadataState>, ) => void;}) { const { theme } = useTheme(); const thumbnailInputRef = useRef<HTMLInputElement | null>(null);
const toggleWarning = useCallback( (value: string) => { setMeta((prev) => { const next = new Set(prev.warnings); next.has(value) ? next.delete(value) : next.add(value); return { warnings: next }; }); }, [setMeta], );
const handleThumbnailSelect = useCallback(() => { thumbnailInputRef.current?.click(); }, []);
const handleThumbnailChange = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0] ?? null; if (f) { const blob = new Blob([f], { type: f.type }); const url = URL.createObjectURL(blob); setMeta((prev) => ({ ...prev, thumbnail: blob, thumbnailUrl: url })); } e.target.value = ""; }, [setMeta], );
const handleThumbnailRemove = useCallback(() => { setMeta((prev) => ({ ...prev, thumbnail: undefined, thumbnailUrl: undefined, })); }, [setMeta]);
// clean up old blob URLs when thumbnail changes or unmounts useEffect(() => { return () => { if (meta.thumbnailUrl) URL.revokeObjectURL(meta.thumbnailUrl); }; }, [meta.thumbnailUrl]);
const setField = useCallback( <K extends keyof MetadataState>(key: K, value: MetadataState[K]) => { setMeta((prev) => ({ ...prev, [key]: value })); }, [setMeta], );
return ( <View style={{ flex: 3, width: "100%", gap: 12 }}> <MenuContainer> {/* title + description */} <View style={{ gap: 6 }}> <MenuLabel>Details</MenuLabel> <MenuGroup> <View style={[zero.px[3], zero.py[1], { gap: 4 }]}> <Text size="sm" color="muted"> Title </Text> <TextInput value={meta.title} onChangeText={(v) => setField("title", v)} maxLength={140} placeholder="Give your video a title" placeholderTextColor={theme.colors.mutedForeground} style={inputStyle(theme)} /> </View> <MenuSeparator /> <View style={[zero.px[3], zero.py[1], { gap: 4 }]}> <Text size="sm" color="muted"> Description </Text> <TextInput value={meta.description} onChangeText={(v) => setField("description", v)} maxLength={5000} multiline numberOfLines={4} placeholder="Describe your video…" placeholderTextColor={theme.colors.mutedForeground} textAlignVertical="top" style={[inputStyle(theme), { minHeight: 80 }]} /> </View> </MenuGroup> </View>
{/* activity */} <View style={{ gap: 6 }}> <MenuLabel>Activity</MenuLabel> <MenuGroup> <View style={[zero.px[3], zero.py[2]]}> <ActivityPicker value={meta.activity} onChange={(v) => setField("activity", v)} /> </View> </MenuGroup> </View>
{/* tags */} <View style={{ gap: 6 }}> <MenuLabel>Tags</MenuLabel> <MenuGroup> <View style={[zero.px[3], zero.py[2], { gap: 8 }]}> {meta.tags.length > 0 && ( <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, }} > {meta.tags.map((tag) => ( <Pressable key={tag} onPress={() => setMeta((prev) => ({ ...prev, tags: prev.tags.filter((t) => t !== tag), })) } style={{ flexDirection: "row", alignItems: "center", backgroundColor: theme.colors.primary + "22", borderRadius: 12, paddingHorizontal: 10, paddingVertical: 3, }} > <Text size="sm" style={{ color: theme.colors.primary }}> {tag} </Text> <Text size="sm" style={{ color: theme.colors.primary, marginLeft: 4, }} > × </Text> </Pressable> ))} </View> )} {meta.tags.length < 10 && ( <TextInput value={meta.tagInput} onChangeText={(v) => setField("tagInput", v.replace(/[^a-zA-Z0-9:]/g, "")) } placeholder="Add tag, press Enter" placeholderTextColor={theme.colors.mutedForeground} returnKeyType="done" onSubmitEditing={() => { const t = meta.tagInput.trim(); if (t && !meta.tags.includes(t)) setMeta((prev) => ({ ...prev, tags: [...prev.tags, t], tagInput: "", })); else setMeta((prev) => ({ ...prev, tagInput: "" })); }} style={inputStyle(theme)} /> )} </View> </MenuGroup> </View>
{/* content warnings */} <View style={{ gap: 6 }}> <MenuLabel>Content Warnings</MenuLabel> <MenuGroup> <View style={[zero.px[3], zero.py[2], { gap: 8 }]}> {CONTENT_WARNINGS.map((cw) => ( <Tooltip key={cw.value} content={cw.description} position="top"> <Checkbox checked={meta.warnings.has(cw.value)} onCheckedChange={() => toggleWarning(cw.value)} label={cw.label} size="sm" /> </Tooltip> ))} </View> </MenuGroup> </View>
{/* license */} <View style={{ gap: 6 }}> <MenuLabel>License</MenuLabel> <MenuGroup> <View style={[zero.px[3], zero.py[2]]}> <Select value={meta.license} onValueChange={(v) => setField("license", v)} placeholder="All rights reserved" items={LICENSE_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value, description: opt.description, }))} style={[ zero.p[3], zero.r.md, { borderColor: theme.colors.border }, zero.borders.width.thin, { backgroundColor: theme.colors.background }, ]} /> </View> </MenuGroup> </View>
{/* thumbnail */} <View style={{ gap: 6 }}> <MenuLabel>Thumbnail</MenuLabel> <MenuGroup> <View style={[zero.px[3], zero.py[2], { gap: 8 }]}> {meta.thumbnailUrl ? ( <View style={{ position: "relative" }}> <Image source={{ uri: meta.thumbnailUrl }} style={{ width: "100%", height: 200, borderRadius: 8, }} contentFit="cover" /> <Pressable onPress={handleThumbnailRemove} style={{ position: "absolute", top: 8, right: 8, backgroundColor: scrims.dark, borderRadius: 12, width: 24, height: 24, alignItems: "center", justifyContent: "center", }} > <X size={16} color="white" /> </Pressable> </View> ) : ( <Pressable onPress={handleThumbnailSelect} style={[ zero.borders.width.medium, { borderColor: theme.colors.border }, zero.r.md, { height: 120, borderStyle: "dashed", alignItems: "center", justifyContent: "center", gap: 4, backgroundColor: theme.colors.background, }, ]} > <ImagePlus size={32} color={theme.colors.mutedForeground} /> <Text size="sm" color="muted"> Add thumbnail image </Text> <Text size="xs" color="muted"> Optional · JPG, PNG up to 975KB </Text> </Pressable> )} {meta.thumbnail && meta.thumbnail.size > 975000 && ( <Admonition variant="warning" size="sm"> <Text size="sm"> Heads up: this image is larger than 975KB. Upload might fail. </Text> </Admonition> )} <input type="file" accept="image/*" ref={thumbnailInputRef} onChange={handleThumbnailChange} style={{ display: "none" }} /> </View> </MenuGroup> </View> </MenuContainer> </View> );}
// ── 1. /upload — bare upload box ─────────────────────────────────────────────
export default function UploadScreen() { const gate = useUploadGate(); const { theme, zero: zt } = useTheme(); const { width } = useWindowDimensions(); const navigation = useNavigation();
const fileInputRef = useRef<HTMLInputElement | null>(null); const processingAnim = useRef(new Animated.Value(0)).current; const [file, setFile] = useState<File | null>(null); const [phase, setPhase] = useState<UploadPhase>({ kind: "idle" }); const [dragActive, setDragActive] = useState(false); const dragDepthRef = useRef(0); const c = theme.colors; const track = { height: 6, borderRadius: 3, backgroundColor: c.surface3, overflow: "hidden" as const, width: "100%" as const, };
const acceptFile = useCallback((f: File | null | undefined) => { if (!f) return; if (!f.type.startsWith("video/")) { setPhase({ kind: "error", message: "That doesn't look like a video. Try MP4, MOV, or WebM.", }); return; } setFile(f); setPhase({ kind: "idle" }); }, []);
const dropzoneVisible = !file && (phase.kind === "idle" || phase.kind === "error"); const selectedVisible = !!file && (phase.kind === "idle" || phase.kind === "error"); const progressVisible = phase.kind === "creating" || phase.kind === "uploading" || phase.kind === "processing" || phase.kind === "publishing"; const doneVisible = phase.kind === "done" || phase.kind === "ready";
const NEXT_STEPS: { icon: any; title: string; body: string }[] = [ { icon: Film, title: "We process it", body: "Transcoded for smooth, adaptive playback.", }, { icon: Pencil, title: "Add details", body: "Title, thumbnail, tags, and more.", }, { icon: Sparkles, title: "Publish", body: "Share it to your channel when ready.", }, ];
// indeterminate progress bar animation useEffect(() => { if ( phase.kind === "creating" || phase.kind === "processing" || phase.kind === "publishing" ) { const anim = Animated.loop( Animated.sequence([ Animated.timing(processingAnim, { toValue: 1, duration: 1200, useNativeDriver: false, }), Animated.timing(processingAnim, { toValue: 0, duration: 1200, useNativeDriver: false, }), ]), ); anim.start(); return () => anim.stop(); } else { processingAnim.setValue(0); } }, [phase.kind, processingAnim]);
const pickFile = useCallback(() => fileInputRef.current?.click(), []);
const handleFileChange = useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { acceptFile(e.target.files?.[0] ?? null); e.target.value = ""; }, [acceptFile], );
const agent = gate.state === "ok" ? gate.agent : null;
const startUpload = useCallback(async () => { if (!agent || !file) return; if (!agent.did) { setPhase({ kind: "error", message: "Not logged in" }); return; } const validationError = await validateVideoFile(file); if (validationError) { setPhase({ kind: "error", message: validationError }); return; } const mimeType = file.type.startsWith("video/") ? file.type : "video/mp4"; setPhase({ kind: "creating" }); try { // Create the empty draft first — it's the durable anchor for this // upload. The user lands on the draft editor immediately and can edit // metadata while the upload runs; a failed upload leaves the draft // intact so they can re-upload against the same draft. const draftRes = await agent.client.call( place.stream.vod.createDraft, {}, ); const draftUri = draftRes.uri; const tid = tidFromUri(draftUri);
const res = await agent.client.call(place.stream.media.createUpload, { size: file.size, mimeType, filename: file.name, draftUri, }); const { uploadUrl, uploadToken } = res;
// Hand the TUS upload to the module-level upload manager — it survives // this screen unmounting, and the floating indicator in the app shell // shows progress until the bytes are all up. The draft (navigated to // below) flips to 'ready' server-side when processing finishes. uploadManager.startUpload({ file, uploadUrl, uploadToken, tid });
// Navigate to the draft editor now — the upload continues in the // background and fills this draft when it finishes processing. navigation.navigate("UploadVideo" as any, { tid }); } catch (err) { setPhase({ kind: "error", message: err instanceof Error ? err.message : String(err), }); } }, [agent, file, navigation]);
const cancelUpload = useCallback(() => { setPhase({ kind: "idle" }); }, []);
const isUploading = phase.kind === "creating" || phase.kind === "uploading" || phase.kind === "processing"; const canUpload = !!file && (phase.kind === "idle" || phase.kind === "error");
if (gate.state !== "ok") { return <UploadGate>{null}</UploadGate>; }
return ( <ScrollView> <View style={[zero.layout.flex.align.center, zero.px[2], zero.py[6]]}> <View style={{ width: "100%", maxWidth: 960 }}> <View style={{ width: "100%", maxWidth: 680, gap: 20 }}> <Text style={{ color: c.text3, fontSize: 14, lineHeight: 20 }}> Upload a recorded video. It processes into a draft you can title, thumbnail, and publish whenever you are ready. </Text>
{dropzoneVisible && ( <> <div onClick={pickFile} onDragEnter={(e) => { e.preventDefault(); dragDepthRef.current += 1; setDragActive(true); }} onDragOver={(e) => e.preventDefault()} onDragLeave={(e) => { e.preventDefault(); dragDepthRef.current -= 1; if (dragDepthRef.current <= 0) setDragActive(false); }} onDrop={(e) => { e.preventDefault(); dragDepthRef.current = 0; setDragActive(false); acceptFile(e.dataTransfer.files?.[0]); }} style={{ position: "relative", cursor: "pointer", borderRadius: 20, border: `1.5px dashed ${dragActive ? c.primary : c.borderStrong}`, background: dragActive ? `radial-gradient(130% 130% at 50% -10%, ${hexToRgba(c.primary, 0.2)} 0%, ${c.surface1} 58%)` : `radial-gradient(130% 130% at 50% -10%, ${hexToRgba(c.primary, 0.07)} 0%, ${c.surface1} 52%)`, transition: "border-color 160ms ease, background 220ms ease, transform 160ms ease, box-shadow 220ms ease", transform: dragActive ? "scale(1.006)" : "scale(1)", boxShadow: dragActive ? `0 0 0 4px ${hexToRgba(c.primary, 0.12)}` : "none", padding: "60px 24px", }} > <View style={{ alignItems: "center", gap: 16, pointerEvents: "none", }} > <View style={{ width: 78, height: 78, borderRadius: 22, alignItems: "center", justifyContent: "center", backgroundColor: hexToRgba( c.primary, dragActive ? 0.24 : 0.12, ), borderWidth: 1, borderColor: hexToRgba(c.primary, 0.35), }} > <UploadCloud size={34} color={c.primary} /> </View> <View style={{ alignItems: "center", gap: 5 }}> <Text style={{ color: c.text1, fontSize: 18, fontWeight: "600", }} > {dragActive ? "Drop to upload" : "Drag and drop your video"} </Text> <Text style={{ color: c.text3, fontSize: 14 }}> or{" "} <Text style={{ color: c.primary, fontWeight: "600" }}> browse your files </Text> </Text> </View> <View style={{ marginTop: 2, paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: c.surface2, borderWidth: 1, borderColor: c.borderSubtle, }} > <Text style={{ color: c.text4, fontSize: 12, fontFamily: fontFamilies.monoMedium, letterSpacing: 0.4, }} > MP4 · MOV · WEBM </Text> </View> </View> </div>
{phase.kind === "error" && ( <View style={{ flexDirection: "row", alignItems: "center", gap: 8, }} > <AlertCircle size={16} color={c.destructive} /> <Text size="sm" color="destructive"> {phase.message} </Text> </View> )}
<View style={{ flexDirection: "row", gap: 10 }}> {NEXT_STEPS.map((step, i) => ( <View key={step.title} style={{ flex: 1, gap: 8, padding: 14, borderRadius: 14, backgroundColor: c.surface1, borderWidth: 1, borderColor: c.borderSubtle, }} > <View style={{ flexDirection: "row", alignItems: "center", gap: 8, }} > <View style={{ width: 26, height: 26, borderRadius: 999, alignItems: "center", justifyContent: "center", backgroundColor: c.surface3, }} > <Text style={{ color: c.text3, fontSize: 11, fontWeight: "700", }} > {i + 1} </Text> </View> <step.icon size={16} color={c.text2} /> </View> <View style={{ gap: 2 }}> <Text style={{ color: c.text1, fontSize: 13, fontWeight: "600", }} > {step.title} </Text> <Text style={{ color: c.text4, fontSize: 12, lineHeight: 16, }} > {step.body} </Text> </View> </View> ))} </View> </> )}
{selectedVisible && file && ( <View style={{ gap: 14 }}> <View style={{ flexDirection: "row", alignItems: "center", gap: 14, backgroundColor: c.surface1, borderWidth: 1, borderColor: c.borderStrong, borderRadius: 16, padding: 14, }} > <View style={{ width: 52, height: 52, borderRadius: 12, alignItems: "center", justifyContent: "center", backgroundColor: hexToRgba(c.primary, 0.12), borderWidth: 1, borderColor: hexToRgba(c.primary, 0.3), }} > <FileVideo size={24} color={c.primary} /> </View> <View style={{ flex: 1, minWidth: 0, gap: 3 }}> <Text numberOfLines={1} style={{ color: c.text1, fontSize: 15, fontWeight: "600", }} > {file.name} </Text> <Text style={{ color: c.text3, fontSize: 13, fontFamily: fontFamilies.monoRegular, }} > {humanBytes(file.size)} ·{" "} {(file.type || "video") .replace("video/", "") .toUpperCase() || "VIDEO"} </Text> </View> <Pressable onPress={() => { setFile(null); setPhase({ kind: "idle" }); }} style={{ width: 32, height: 32, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: c.surface2, }} > <X size={16} color={c.text3} /> </Pressable> </View>
{phase.kind === "error" && ( <View style={{ flexDirection: "row", alignItems: "center", gap: 8, }} > <AlertCircle size={16} color={c.destructive} /> <Text size="sm" color="destructive"> {phase.message} </Text> </View> )}
<View style={{ gap: 8 }}> <Button onPress={startUpload}>Upload video</Button> <Button variant="secondary" onPress={pickFile}> Choose a different file </Button> </View> </View> )}
{progressVisible && ( <View style={{ gap: 14, backgroundColor: c.surface1, borderWidth: 1, borderColor: c.borderStrong, borderRadius: 16, padding: 18, }} > {file && ( <View style={{ flexDirection: "row", alignItems: "center", gap: 10, }} > <FileVideo size={18} color={c.text3} /> <Text numberOfLines={1} style={{ color: c.text2, fontSize: 14, flex: 1 }} > {file.name} </Text> </View> )} <View style={{ gap: 8 }}> <View style={{ flexDirection: "row", alignItems: "center", gap: 10, }} > {phase.kind === "uploading" ? ( <ArrowUp size={16} color={c.primary} /> ) : ( <LoaderCircle size={16} color={c.primary} /> )} <Text size="sm" color="muted"> {phase.kind === "creating" ? "Preparing your upload…" : phase.kind === "uploading" ? `Uploading — ${phase.pct.toFixed(0)}% · ${humanBytes(phase.bytesSent)} / ${humanBytes(phase.bytesTotal)}` : phase.kind === "processing" ? phase.serverStatus === "processing" ? `Processing${phase.progress != null ? ` (${phase.progress}%)` : "…"}` : "Waiting to process…" : "Publishing…"} </Text> </View> {phase.kind === "uploading" ? ( <View style={track}> <View style={{ height: 6, width: `${phase.pct}%`, backgroundColor: c.primary, borderRadius: 3, }} /> </View> ) : phase.kind === "processing" && phase.progress != null ? ( <View style={track}> <View style={{ height: 6, width: `${phase.progress}%`, backgroundColor: c.primary, borderRadius: 3, }} /> </View> ) : ( <Animated.View style={track}> <Animated.View style={{ height: 6, borderRadius: 3, backgroundColor: c.primary, width: processingAnim.interpolate({ inputRange: [0, 1], outputRange: ["0%", "100%"], }), opacity: processingAnim.interpolate({ inputRange: [0, 0.5, 1], outputRange: [0.4, 1, 0.4], }), }} /> </Animated.View> )} </View> {isUploading && phase.kind !== "processing" && ( <Button variant="danger" onPress={cancelUpload}> Cancel </Button> )} </View> )}
{doneVisible && ( <View style={{ gap: 12, backgroundColor: c.surface1, borderWidth: 1, borderColor: c.borderStrong, borderRadius: 16, padding: 20, alignItems: "flex-start", }} > <View style={{ flexDirection: "row", alignItems: "center", gap: 10, }} > <CheckCircle2 size={22} color={c.success} /> <Text style={{ color: c.text1, fontSize: 16, fontWeight: "600" }} > {phase.kind === "ready" ? "Ready to publish" : "Upload complete"} </Text> </View> <Text style={{ color: c.text3, fontSize: 14, lineHeight: 20 }}> Your video is processing into a draft. Add the finishing touches and publish it from your Drafts. </Text> <View style={{ flexDirection: "row", gap: 8 }}> <Button onPress={() => navigation.navigate("UploadDrafts" as any)} > Go to drafts </Button> <Button variant="secondary" onPress={() => { setFile(null); setPhase({ kind: "idle" }); }} > Upload another </Button> </View> </View> )} </View> </View> </View>
<input type="file" accept="video/*" ref={fileInputRef} onChange={handleFileChange} style={{ display: "none" }} /> </ScrollView> );}
// ── 2. /upload/video/:tid — unified draft/published editor ──────────────────
export function UploadVideoScreen({ route }: { route: any }) { const gate = useUploadGate(); const { theme } = useTheme(); const navigation = useNavigation(); const { width } = useWindowDimensions(); const isWide = width > 800; const { tid } = route.params as { tid: string };
const [loading, setLoading] = useState(true); // "draft" | "video" | null until loaded const [mode, setMode] = useState<"draft" | "video" | null>(null); const [draftUri_, setDraftUri] = useState<string | undefined>(undefined); const [videoUri_, setVideoUri] = useState<string | undefined>(undefined); const [draftStatus, setDraftStatus] = useState< place.stream.vod.draftVideo.Main["status"] | undefined >(undefined); const [meta, setMetaState] = useState<MetadataState>(DEFAULT_METADATA); const [updating, setUpdating] = useState(false); const [deleting, setDeleting] = useState(false); const [publishing, setPublishing] = useState(false); const [error, setError] = useState<string | undefined>(undefined);
const setMeta = useCallback( ( updater: (prev: MetadataState) => MetadataState | Partial<MetadataState>, ) => { setMetaState((prev) => { const next = updater(prev); return { ...prev, ...(next as Partial<MetadataState>) }; }); }, [], );
const agent = gate.state === "ok" ? gate.agent : null;
// Load the record: try draft first (getDraft), fall back to published video // (getRecord). Drafts and published videos share the same tid. useEffect(() => { if (!agent || !agent.did) return; let cancelled = false; setLoading(true); setError(undefined); (async () => { const did = agent.did!; const duri = draftUri(did, tid); try { const res = await agent.client.call(place.stream.vod.getDraft, { uri: duri, }); if (cancelled) return; const rec = res.draft.record as place.stream.vod.draftVideo.Main; setMode("draft"); setDraftUri(duri); setVideoUri(undefined); setDraftStatus(rec.status); setMetaState({ title: rec.title || "", description: rec.description || "", activity: rec.activity || undefined, tags: rec.tags || [], tagInput: "", thumbnail: undefined, thumbnailUrl: rec.thumb ? `https://cdn.stream.place/thumb/${(rec.thumb as any).ref?.$link || (rec.thumb as any).cid || ""}` : undefined, warnings: new Set(rec.contentWarnings?.warnings || []), license: (rec.contentRights as any)?.license?.$type || (rec.contentRights as any)?.license || "place.stream.metadata.contentRights#all-rights-reserved", }); setLoading(false); } catch (err: any) { if (cancelled) return; // NotFound → fall back to published video const vuri = videoUri(did, tid); try { const existing = await agent.client.get(place.stream.video, { rkey: tid as any, }); if (cancelled) return; const rec = existing.value as any; setMode("video"); setVideoUri(vuri); setDraftUri(undefined); setDraftStatus(undefined); setMetaState({ title: rec.title || "", description: rec.description || "", activity: rec.activity || undefined, tags: rec.tags || [], tagInput: "", thumbnail: undefined, thumbnailUrl: rec.thumb ? `https://cdn.stream.place/thumb/${(rec.thumb as any).ref?.$link || (rec.thumb as any).cid || ""}` : undefined, warnings: new Set(rec.contentWarnings?.warnings || []), license: (rec.contentRights as any)?.license?.$type || (rec.contentRights as any)?.license || "place.stream.metadata.contentRights#all-rights-reserved", }); setLoading(false); } catch (err2) { if (cancelled) return; console.error("Failed to load video or draft", err2); setError("Could not load this video. It may not exist."); setLoading(false); } } })(); return () => { cancelled = true; }; }, [agent, tid]);
// While the draft is still processing, poll so the editor reflects the // processing → ready (or error) transition without a manual reload. Only // refreshes the status (and source/durationMs once ready), never the // editable metadata the user may be mid-edit on. useEffect(() => { if ( mode !== "draft" || draftStatus !== "processing" || !agent || !agent.did ) { return; } let cancelled = false; const poll = async () => { try { const res = await agent.client.call(place.stream.vod.getDraft, { uri: draftUri(agent.did!, tid), }); if (cancelled) return; const rec = res.draft.record as place.stream.vod.draftVideo.Main; setDraftStatus(rec.status); } catch { // Transient fetch failures are fine; the next tick retries. } }; const interval = setInterval(poll, 3000); return () => { cancelled = true; clearInterval(interval); }; }, [mode, draftStatus, agent, tid]);
const handleSave = useCallback(async () => { if (!agent || !agent.did) return; setUpdating(true); setError(undefined); try { if (mode === "draft" && draftUri_) { const params: Record<string, any> = { uri: draftUri_, title: meta.title.trim() || "Untitled", }; if (meta.description.trim()) params.description = meta.description.trim(); if (meta.activity) params.activity = meta.activity; if (meta.tags.length > 0) params.tags = meta.tags; if (meta.warnings.size > 0) { params.contentWarnings = { $type: "place.stream.metadata.contentWarnings", warnings: [...meta.warnings], }; } if ( meta.license && meta.license !== "place.stream.metadata.contentRights#all-rights-reserved" ) { params.contentRights = { $type: "place.stream.metadata.contentRights", license: meta.license, }; } if (meta.thumbnail) { try { const blobRes = await agent.client.uploadBlob(meta.thumbnail, { encoding: (meta.thumbnail.type || "image/jpeg") as any, }); params.thumb = blobRes.body.blob; } catch { // thumbnail failure is non-fatal } } await agent.client.call(place.stream.vod.updateDraft, params as any); } else if (mode === "video") { // Published video: putRecord. Preserve source/duration/createdAt. const existing = await agent.client.get(place.stream.video, { rkey: tid as any, }); const existingRec = existing.value as any; const record: Record<string, any> = { $type: "place.stream.video", title: meta.title.trim(), source: existingRec.source, durationMs: existingRec.durationMs, createdAt: existingRec.createdAt || new Date().toISOString(), }; if (meta.description.trim()) record.description = meta.description.trim(); if (meta.activity) record.activity = meta.activity; if (meta.tags.length > 0) record.tags = meta.tags; if (meta.warnings.size > 0) { record.contentWarnings = { $type: "place.stream.metadata.contentWarnings", warnings: [...meta.warnings], }; } if ( meta.license && meta.license !== "place.stream.metadata.contentRights#all-rights-reserved" ) { record.contentRights = { $type: "place.stream.metadata.contentRights", license: meta.license, }; } if (meta.thumbnail) { try { const blobRes = await agent.client.uploadBlob(meta.thumbnail, { encoding: (meta.thumbnail.type || "image/jpeg") as any, }); record.thumb = blobRes.body.blob; } catch { // thumbnail is non-fatal } } const { $type: _videoType, ...videoRecordInput } = record; await agent.client.put(place.stream.video, videoRecordInput as any, { rkey: tid as any, }); } } catch (err) { console.error("Failed to save", err); setError(err instanceof Error ? err.message : "Failed to save changes."); } finally { setUpdating(false); } }, [agent, mode, draftUri_, tid, meta]);
const handlePublish = useCallback(async () => { if (!agent || !draftUri_) return; setPublishing(true); setError(undefined); try { const res = await agent.client.call(place.stream.vod.publishDraft, { uri: draftUri_, }); // Navigate to the player route for the published video. const publishedTid = tidFromUri(res.videoUri); navigation.navigate("Video" as any, { user: agent.did ?? "", tid: publishedTid, }); } catch (err) { console.error("Failed to publish draft", err); setError(err instanceof Error ? err.message : "Failed to publish."); } finally { setPublishing(false); } }, [agent, draftUri_, navigation]);
const handleDelete = useCallback(async () => { if (!agent || !agent.did) return; setDeleting(true); setError(undefined); try { if (mode === "draft" && draftUri_) { await agent.client.call(place.stream.vod.deleteDraft, { uri: draftUri_, }); } else if (mode === "video") { await agent.client.delete(place.stream.video, { rkey: tid as any }); } navigation.navigate("UploadVideos" as any); } catch (err) { console.error("Failed to delete", err); setError(err instanceof Error ? err.message : "Failed to delete."); } finally { setDeleting(false); } }, [agent, mode, draftUri_, tid, navigation]);
if (gate.state !== "ok") { return <UploadGate>{null}</UploadGate>; } if (loading) return <Loading />; if (error && !mode) { return ( <ScrollView> <View style={[ zero.layout.flex.align.center, zero.px[2], zero.pt[8], zero.pb[6], ]} > <UploadTabNav activeScreen={mode === "video" ? "UploadVideos" : "UploadDrafts"} /> <View style={{ maxWidth: 960, width: "100%" }}> <View style={[zero.p[6], { alignItems: "center", gap: 12 }]}> <AlertCircle size={32} color={theme.colors.destructive} /> <Text color="destructive">{error}</Text> </View> </View> </View> </ScrollView> ); }
return ( <ScrollView> <View style={[zero.layout.flex.align.center, zero.px[2], zero.py[2]]}> <UploadTabNav activeScreen={mode === "video" ? "UploadVideos" : "UploadDrafts"} />
{/* Editor header */} <View style={{ maxWidth: 960, width: "100%", flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 12, }} > <Text size="sm" color="muted"> {mode === "draft" ? "Editing draft" : "Editing video"} {draftStatus ? ` · ${draftStatus === "processing" ? "Processing" : draftStatus === "error" ? "Error" : "Ready"}` : ""} </Text> </View>
{error && ( <View style={{ maxWidth: 960, width: "100%", marginBottom: 12 }}> <Admonition variant="error" size="sm"> <Text size="sm">{error}</Text> </Admonition> </View> )}
<View style={{ maxWidth: 960, width: "100%", flexDirection: isWide ? "row" : "column", alignItems: "flex-start", gap: 12, }} > <MetadataEditor meta={meta} setMeta={setMeta} />
{/* Right column: actions. Sticks to the top of the viewport once it scrolls up, so Save/Publish stay reachable through the long form. Web-only (native has no sticky) and only in the two-column layout — stacked, it lives inline under the form. */} <View style={[ { flex: isWide ? 2 : undefined, width: "100%", gap: 12 }, isWide && Platform.OS === "web" ? ({ position: "sticky", top: 16 } as any) : null, ]} > <MenuContainer> <View style={{ gap: 6 }}> <MenuLabel>Actions</MenuLabel> <MenuGroup> <View style={[zero.px[3], zero.py[2], { gap: 8 }]}> {/* Publish is the primary action (pink); Save Draft is a quieter secondary so the hierarchy reads at a glance. */} <Button variant={ mode === "draft" && draftStatus === "ready" ? "secondary" : "primary" } onPress={handleSave} disabled={!meta.title.trim() || updating} > {updating ? "Saving…" : mode === "draft" ? "Save Draft" : "Update Video"} </Button> {mode === "draft" && draftStatus === "ready" && ( <Button variant="primary" onPress={handlePublish} disabled={publishing || updating} > {publishing ? "Publishing…" : "Publish"} </Button> )} {mode === "draft" && draftStatus === "processing" && ( <View style={{ flexDirection: "row", alignItems: "center", gap: 6, }} > <LoaderCircle size={16} color={theme.colors.mutedForeground} /> <Text size="xs" color="muted"> Processing — publish will be available when ready. </Text> </View> )} {mode === "draft" && draftStatus === "error" && ( <Admonition variant="error" size="sm"> <Text size="sm"> Processing failed. You can discard this draft. </Text> </Admonition> )}
{/* Destructive action, set apart below a hairline. */} <View style={{ height: 1, backgroundColor: theme.colors.borderSubtle, marginVertical: 2, }} /> <Button variant="danger" onPress={handleDelete} disabled={deleting} > {deleting ? "Deleting…" : mode === "draft" ? "Delete draft" : "Delete video"} </Button> </View> </MenuGroup> </View> </MenuContainer> </View> </View> </View> </ScrollView> );}
// ── 3. /upload/drafts — drafts list ──────────────────────────────────────────
export function UploadDraftsScreen() { const gate = useUploadGate(); const { theme } = useTheme(); const navigation = useNavigation();
const [drafts, setDrafts] = useState<place.stream.vod.draftDefs.DraftView[]>( [], ); const [draftsLoading, setDraftsLoading] = useState(false); const [draftAction, setDraftAction] = useState< Record<string, "publishing" | "deleting" | "error"> >({}); // Optimistically-recorded published video URIs: publishDraft returns the new // place.stream.video URI, which we surface as a "View VOD" link before the // drafts list refresh removes the row server-side. const [publishedFromDrafts, setPublishedFromDrafts] = useState< Record<string, string> >({});
const agent = gate.state === "ok" ? gate.agent : null;
const fetchDrafts = useCallback(async () => { if (!agent) return; setDraftsLoading(true); try { const res = await agent.client.call(place.stream.vod.listDrafts, { limit: 100, }); setDrafts(res.drafts || []); } catch (err) { console.error("Failed to fetch drafts", err); } finally { setDraftsLoading(false); } }, [agent]);
useEffect(() => { if (gate.state === "ok") fetchDrafts(); }, [gate.state, fetchDrafts]);
const handlePublishDraft = useCallback( async (uri: string) => { if (!agent) return; setDraftAction((m) => ({ ...m, [uri]: "publishing" })); try { const res = await agent.client.call(place.stream.vod.publishDraft, { uri, }); setPublishedFromDrafts((m) => ({ ...m, [uri]: res.videoUri })); setDrafts((prev) => prev.filter((d) => d.uri !== uri)); } catch (err) { console.error("Failed to publish draft", err); setDraftAction((m) => ({ ...m, [uri]: "error" })); } }, [agent], );
const handleDeleteDraft = useCallback( async (uri: string) => { if (!agent) return; setDraftAction((m) => ({ ...m, [uri]: "deleting" })); try { await agent.client.call(place.stream.vod.deleteDraft, { uri }); setDrafts((prev) => prev.filter((d) => d.uri !== uri)); } catch (err) { console.error("Failed to delete draft", err); setDraftAction((m) => ({ ...m, [uri]: "error" })); } }, [agent], );
if (gate.state !== "ok") { return <UploadGate>{null}</UploadGate>; }
return ( <ScrollView contentContainerStyle={{ flexGrow: 1 }}> <View style={[ zero.layout.flex.align.center, zero.px[2], zero.pt[8], zero.pb[6], { flex: 1 }, ]} > <UploadTabNav />
<View style={{ maxWidth: 960, width: "100%", flex: 1, gap: 12 }}> {draftsLoading && drafts.length === 0 && ( <View style={{ flex: 1, minHeight: 320, alignItems: "center", justifyContent: "center", }} > <LoaderCircle size={32} color={theme.colors.mutedForeground} /> </View> )} {!draftsLoading && drafts.length === 0 && ( <EmptyState illustration={<EmptyStateTile icon={FileVideo} />} title="No drafts yet" subtitle="Uploads and finalized livestreams appear here as drafts while they process — come back to add details and publish." /> )} {drafts.map((draft) => { const rec = draft.record as place.stream.vod.draftVideo.Main; const status = rec.status; const action = draftAction[draft.uri]; const publishedUri = publishedFromDrafts[draft.uri]; return ( <View key={draft.uri} style={[ zero.p[3], zero.r.md, { flexDirection: "row", gap: 12, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.background, alignItems: "center", }, ]} > <View style={{ flex: 1, gap: 2 }}> <Text numberOfLines={1} style={{ fontWeight: "600", fontSize: 14 }} > {rec.title || "Untitled"} </Text> <Text size="xs" color="muted"> {rec.createdAt ? new Date(rec.createdAt).toLocaleString() : ""} {" · "} {status === "processing" ? "Processing" : status === "error" ? `Error: ${rec.error || "unknown"}` : "Ready"} </Text> </View> {publishedUri ? ( <AQLink to={{ screen: "Video", params: { user: agent?.did ?? "", tid: tidFromUri(publishedUri), }, }} > <View style={{ flexDirection: "row", alignItems: "center", gap: 6, }} > <CheckCircle2 size={16} color={theme.colors.primary} /> <Text size="sm" style={{ color: theme.colors.primary }}> View VOD </Text> </View> </AQLink> ) : action === "publishing" || action === "deleting" ? ( <View style={{ flexDirection: "row", alignItems: "center", gap: 6, }} > <LoaderCircle size={16} color={theme.colors.mutedForeground} /> <Text size="xs" color="muted"> {action === "publishing" ? "Publishing…" : "Deleting…"} </Text> </View> ) : status === "ready" ? ( <View style={{ flexDirection: "row", gap: 8 }}> <Button size="sm" style={[{ width: "auto" }]} variant="secondary" onPress={() => navigation.navigate("UploadVideo" as any, { tid: tidFromUri(draft.uri), }) } > Edit </Button> <Button size="sm" style={[{ width: "auto" }]} onPress={() => handlePublishDraft(draft.uri)} > Publish </Button> <Button size="sm" style={[{ width: "auto" }]} variant="danger" onPress={() => handleDeleteDraft(draft.uri)} > <X size={14} color={theme.colors.destructiveForeground} /> </Button> </View> ) : status === "error" ? ( <Button size="sm" variant="danger" style={[{ width: "auto" }]} onPress={() => handleDeleteDraft(draft.uri)} > Discard </Button> ) : ( <Button size="sm" variant="danger" style={[{ width: "auto" }]} onPress={() => handleDeleteDraft(draft.uri)} > <X size={14} color={theme.colors.destructiveForeground} /> </Button> )} </View> ); })} </View> </View> </ScrollView> );}
// ── 4. /upload/livestreams — livestream list + finalize ──────────────────────
export function UploadLivestreamsScreen() { const gate = useUploadGate(); const { theme } = useTheme(); const navigation = useNavigation();
const [livestreams, setLivestreams] = useState<any[]>([]); const [livestreamsLoading, setLivestreamsLoading] = useState(false); const [userVideos, setUserVideos] = useState<any[]>([]); // Per-livestream finalize state. With draft VODs, finalize no longer polls // or publishes: it just kicks off the server task and flips the row to // "done" so the user knows to find the draft in the Drafts tab. const [finalizing, setFinalizing] = useState< Record<string, "finalizing" | "done" | "error"> >({}); // Livestream URI -> draft ats:// URI, recorded when finalizeLivestream // returns so the row can offer a deep-link to the draft editor. const [finalizedDraftUris, setFinalizedDraftUris] = useState< Record<string, string> >({});
const agent = gate.state === "ok" ? gate.agent : null;
const fetchVideos = useCallback(async () => { if (!agent || !agent.did) return; try { const res = await agent.client.call(place.stream.media.getVideoList, { repo: agent.did, }); setUserVideos(res.videos || []); } catch (err) { console.error("Failed to fetch videos", err); } }, [agent]);
const fetchLivestreams = useCallback(async () => { if (!agent || !agent.did) return; setLivestreamsLoading(true); try { const res = await agent.client.list(place.stream.livestream, { repo: agent.did as any, limit: 100, }); const records = (res.records ?? []).slice().sort((a, b) => { const at = ((a.value as any)?.createdAt as string) ?? ""; const bt = ((b.value as any)?.createdAt as string) ?? ""; return bt.localeCompare(at); // newest first }); setLivestreams(records); } catch (err) { console.error("Failed to fetch livestreams", err); } finally { setLivestreamsLoading(false); } }, [agent]);
// Cross-reference the user's published videos against their livestreams via // the video record's `connections` array: a finalized livestream is one some // VOD links back to. media.publishVideo preserves client-supplied // connections, so the finalize flow below stamps the source livestream there. const livestreamToVideo = useMemo(() => { const map = new Map<string, string>(); for (const v of userVideos) { const rec = (v.record?.value || v.record || {}) as any; for (const c of rec.connections ?? []) { const uri = c?.ref?.uri; if ( typeof uri === "string" && uri.includes("/place.stream.livestream/") ) { map.set(uri, v.uri); } } } return map; }, [userVideos]);
useEffect(() => { if (gate.state === "ok") { fetchVideos(); fetchLivestreams(); } }, [gate.state, fetchVideos, fetchLivestreams]);
// Finalize a single livestream into a draft VOD. With draft VODs the // client no longer polls getUploadStatus or calls publishVideo: the server // kicks off the remux/processing task and creates a draft record (status // "processing" → "ready") that the user publishes later from the Drafts tab. // We just call finalizeLivestream and flip the row to a "done" state that // points the user at the draft editor. const finalizeLivestreamRow = useCallback( async (ls: { uri: string; cid: string; value: any }) => { if (!agent || !agent.did) return; setFinalizing((m) => ({ ...m, [ls.uri]: "finalizing" })); try { const res = await agent.client.call( place.stream.media.finalizeLivestream, { livestream: ls.uri as any, }, ); // Record the draft URI so the row can offer a deep-link to the // draft editor. if (res.draftUri) { setFinalizedDraftUris((m) => ({ ...m, [ls.uri]: res.draftUri as string, })); } setFinalizing((m) => ({ ...m, [ls.uri]: "done" })); } catch (err) { console.error("Failed to finalize livestream", err); setFinalizing((m) => ({ ...m, [ls.uri]: "error" })); } }, [agent], );
if (gate.state !== "ok") { return <UploadGate>{null}</UploadGate>; }
return ( <ScrollView contentContainerStyle={{ flexGrow: 1 }}> <View style={[ zero.layout.flex.align.center, zero.px[2], zero.pt[8], zero.pb[6], { flex: 1 }, ]} > <UploadTabNav />
<View style={{ maxWidth: 960, width: "100%", flex: 1, gap: 12 }}> {livestreamsLoading && livestreams.length === 0 && ( <View style={{ flex: 1, minHeight: 320, alignItems: "center", justifyContent: "center", }} > <LoaderCircle size={32} color={theme.colors.mutedForeground} /> </View> )} {!livestreamsLoading && livestreams.length === 0 && ( <EmptyState illustration={<EmptyStateTile icon={Radio} />} title="No livestreams yet" subtitle="When you go live, your past streams show up here to finalize into VODs." /> )} {livestreams.map((ls: any) => { const rec = ls.value || {}; const ended = !!rec.endedAt; const vodUri = livestreamToVideo.get(ls.uri); const status = finalizing[ls.uri]; const draftU = finalizedDraftUris[ls.uri]; return ( <View key={ls.uri} style={[ zero.p[3], zero.r.md, { flexDirection: "row", gap: 12, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.background, alignItems: "center", }, ]} > <View style={{ flex: 1, gap: 2 }}> <Text numberOfLines={1} style={{ fontWeight: "600", fontSize: 14 }} > {rec.title || "Untitled livestream"} </Text> <Text size="xs" color="muted"> {rec.createdAt ? new Date(rec.createdAt).toLocaleString() : ""} {ended ? " · Ended" : " · Live"} </Text> </View> {vodUri ? ( <AQLink to={{ screen: "Video", params: { user: agent?.did ?? "", tid: tidFromUri(vodUri), }, }} > <View style={{ flexDirection: "row", alignItems: "center", gap: 6, }} > <CheckCircle2 size={16} color={theme.colors.primary} /> <Text size="sm" style={{ color: theme.colors.primary }}> View VOD </Text> </View> </AQLink> ) : status === "finalizing" ? ( <View style={{ flexDirection: "row", alignItems: "center", gap: 6, }} > <LoaderCircle size={16} color={theme.colors.mutedForeground} /> <Text size="xs" color="muted"> Finalizing… </Text> </View> ) : status === "done" ? ( <Pressable onPress={() => { if (draftU) { navigation.navigate("UploadVideo" as any, { tid: tidFromUri(draftU), }); } else { navigation.navigate("UploadDrafts" as any); } }} style={{ flexDirection: "row", alignItems: "center", gap: 6, }} > <CheckCircle2 size={16} color={theme.colors.success} /> <Text size="sm" style={{ color: theme.colors.success }}> Edit draft → </Text> </Pressable> ) : !ended ? ( <Tooltip content="This stream is still live. Finalize once it has ended."> <Button size="sm" disabled onPress={() => {}}> Finalize </Button> </Tooltip> ) : ( <Button size="sm" onPress={() => finalizeLivestreamRow(ls)} style={{ width: "auto" }} > {status === "error" ? "Retry" : "Finalize"} </Button> )} </View> ); })} </View> </View> </ScrollView> );}
// ── 5. /upload/videos — My Videos list ───────────────────────────────────────
export function UploadVideosScreen() { const gate = useUploadGate(); const { theme } = useTheme(); const navigation = useNavigation();
const [userVideos, setUserVideos] = useState<any[]>([]);
const agent = gate.state === "ok" ? gate.agent : null;
const fetchVideos = useCallback(async () => { if (!agent || !agent.did) return; try { const res = await agent.client.call(place.stream.media.getVideoList, { repo: agent.did, }); setUserVideos(res.videos || []); } catch (err) { console.error("Failed to fetch videos", err); } }, [agent]);
useEffect(() => { if (gate.state === "ok") fetchVideos(); }, [gate.state, fetchVideos]);
if (gate.state !== "ok") { return <UploadGate>{null}</UploadGate>; }
return ( <ScrollView contentContainerStyle={{ flexGrow: 1 }}> <View style={[ zero.layout.flex.align.center, zero.px[2], zero.pt[8], zero.pb[6], { flex: 1 }, ]} > <UploadTabNav />
<View style={{ maxWidth: 960, width: "100%", flex: 1, gap: 12 }}> {userVideos.length === 0 && ( <EmptyState illustration={<EmptyStateTile icon={Video} />} title="No videos yet" subtitle="Hit “Upload video” to add your first one." /> )} {userVideos.map((video: any) => { const rec = video.record?.value || video.record || {}; const thumb = rec.thumb; const thumbUrl = thumb ? `https://cdn.stream.place/thumb/${thumb.ref?.$link || thumb.cid || ""}` : undefined; return ( <Pressable key={video.uri} onPress={() => navigation.navigate("UploadVideo" as any, { tid: tidFromUri(video.uri), }) } style={[ zero.p[3], zero.r.md, { flexDirection: "row", gap: 12, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.background, alignItems: "center", }, ]} > {thumbUrl ? ( <Image source={{ uri: thumbUrl }} style={{ width: 80, height: 45, borderRadius: 4, }} contentFit="cover" /> ) : ( <View style={{ width: 80, height: 45, borderRadius: 4, backgroundColor: theme.colors.muted, alignItems: "center", justifyContent: "center", }} > <Video size={20} color={theme.colors.mutedForeground} /> </View> )} <View style={{ flex: 1, gap: 2 }}> <Text numberOfLines={1} style={{ fontWeight: "600", fontSize: 14 }} > {rec.title || "Untitled"} </Text> <Text size="xs" color="muted"> {video.viewCounts?.count != null && `${video.viewCounts.count} views · `} {rec.durationMs ? `${Math.round(rec.durationMs / 1000)}s` : ""} </Text> </View> </Pressable> ); })} </View> </View> </ScrollView> );}