diff --git a/js/app/src/linking-config.ts b/js/app/src/linking-config.ts index 351c4f155..cdfac859a 100644 --- a/js/app/src/linking-config.ts +++ b/js/app/src/linking-config.ts @@ -106,6 +106,10 @@ export const SCREEN_PATHS = { Multi: "multi/:config", Support: "support", Upload: "upload", + UploadVideo: "upload/video/:tid", + UploadDrafts: "upload/drafts", + UploadLivestreams: "upload/livestreams", + UploadVideos: "upload/videos", VideoList: "video", UserVideoList: ":did/video", // Settings screens @@ -206,6 +210,10 @@ export const streamplaceLinkingOptions: LinkingOptions; + license: string; +}; + +const DEFAULT_METADATA: MetadataState = { + title: "", + description: "", + activity: undefined, + tags: [], + tagInput: "", + thumbnail: undefined, + thumbnailUrl: undefined, + warnings: new Set(), + license: "place.stream.metadata.contentRights#all-rights-reserved", +}; + +const BETA_FEATURE = "vod"; + // ── helpers ─────────────────────────────────────────────────────────────────── function humanBytes(n: number): string { @@ -110,11 +138,36 @@ async function validateVideoFile(file: File): Promise { return "File doesn't appear to be a supported video format (MP4, WebM, MKV, MOV, AVI, OGG, FLV, MPEG-TS)."; } -// ── screen ─────────────────────────────────────────────────────────────────── +// 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() ?? ""; +} -const BETA_FEATURE = "vod"; +// 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}`; +} -export default function UploadScreen() { +// 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(); @@ -125,18 +178,489 @@ export default function UploadScreen() { 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 ; + if (gate.state === "login") { + return ( + + + + Log in to upload videos + + + You need to be logged in to upload and manage your videos. + + + + + ); + } + if (gate.state === "beta") { + return ( + + ); + } + return <>{children}; +} + +// ── tab nav row ────────────────────────────────────────────────────────────── + +// The sub-nav row that used to switch managerMode. Now each entry is a real +// navigation.navigate() call. Renders the same segmented-control style. +function UploadTabNav() { + const navigation = useNavigation(); + const { theme } = useTheme(); + const route = useRoute(); + + type TabDef = { + label: string; + screen: "Upload" | "UploadVideos" | "UploadLivestreams" | "UploadDrafts"; + position: "left" | "middle" | "right"; + }; + const tabs: TabDef[] = [ + { label: "Upload", screen: "Upload", position: "left" }, + { label: "My Videos", screen: "UploadVideos", position: "middle" }, + { label: "Livestreams", screen: "UploadLivestreams", position: "middle" }, + { label: "Drafts", screen: "UploadDrafts", position: "right" }, + ]; + + return ( + + {tabs.map((tab) => { + const active = route.name === tab.screen; + const radiusOverride = + tab.position === "left" + ? { borderTopRightRadius: 0, borderBottomRightRadius: 0 } + : tab.position === "right" + ? { borderTopLeftRadius: 0, borderBottomLeftRadius: 0 } + : { borderRadius: 0 }; + return ( + navigation.navigate(tab.screen as any)} + style={[ + zero.px[4], + zero.py[2], + zero.r.lg, + radiusOverride, + active + ? { backgroundColor: theme.colors.primary } + : { + backgroundColor: "transparent", + borderWidth: 1, + borderColor: theme.colors.border, + ...(tab.position === "left" ? {} : { borderLeftWidth: 0 }), + }, + ]} + > + + {tab.label} + + + ); + })} + + ); +} + +// ── 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, + ) => void; +}) { + const { theme } = useTheme(); + const thumbnailInputRef = useRef(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) => { + 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( + (key: K, value: MetadataState[K]) => { + setMeta((prev) => ({ ...prev, [key]: value })); + }, + [setMeta], + ); + + return ( + + + {/* title + description */} + + Details + + + + Title + + setField("title", v)} + maxLength={140} + placeholder="Give your video a title" + placeholderTextColor={theme.colors.mutedForeground} + style={inputStyle(theme)} + /> + + + + + Description + + setField("description", v)} + maxLength={5000} + multiline + numberOfLines={4} + placeholder="Describe your video…" + placeholderTextColor={theme.colors.mutedForeground} + textAlignVertical="top" + style={[inputStyle(theme), { minHeight: 80 }]} + /> + + + + + {/* activity */} + + Activity + + + setField("activity", v)} + /> + + + + + {/* tags */} + + Tags + + + {meta.tags.length > 0 && ( + + {meta.tags.map((tag) => ( + + 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, + }} + > + + {tag} + + + × + + + ))} + + )} + {meta.tags.length < 10 && ( + + 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)} + /> + )} + + + + + {/* content warnings */} + + Content Warnings + + + {CONTENT_WARNINGS.map((cw) => ( + + toggleWarning(cw.value)} + label={cw.label} + size="sm" + /> + + ))} + + + + + {/* license */} + + License + + + + + + + + + ); +} + +// ── 1. /upload — bare upload box ───────────────────────────────────────────── + +export default function UploadScreen() { + const gate = useUploadGate(); const { theme, zero: zt } = useTheme(); const { width } = useWindowDimensions(); - const isWide = width > 800; + const navigation = useNavigation(); - // file const fileInputRef = useRef(null); const uploadRef = useRef(null); const pollRef = useRef | null>(null); const processingAnim = useRef(new Animated.Value(0)).current; const [file, setFile] = useState(null); - - // upload state machine const [phase, setPhase] = useState({ kind: "idle" }); // indeterminate progress bar animation @@ -167,23 +691,6 @@ export default function UploadScreen() { } }, [phase.kind, processingAnim]); - // metadata form — always editable - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [activity, setActivity] = - useState(undefined); - const [tags, setTags] = useState([]); - const [tagInput, setTagInput] = useState(""); - const [thumbnail, setThumbnail] = useState(undefined); - const [thumbnailUrl, setThumbnailUrl] = useState( - undefined, - ); - const thumbnailInputRef = useRef(null); - const [warnings, setWarnings] = useState>(new Set()); - const [license, setLicense] = useState( - "place.stream.metadata.contentRights#all-rights-reserved", - ); - // cleanup on unmount useEffect(() => { return () => { @@ -192,60 +699,19 @@ export default function UploadScreen() { }; }, []); - // clean up old blob URLs when thumbnail changes or unmounts - useEffect(() => { - return () => { - if (thumbnailUrl) URL.revokeObjectURL(thumbnailUrl); - }; - }, [thumbnailUrl]); - const pickFile = useCallback(() => fileInputRef.current?.click(), []); const handleFileChange = useCallback( (e: React.ChangeEvent) => { const f = e.target.files?.[0] ?? null; setFile(f); - if (f) setTitle((t) => t || f.name.replace(/\.[^.]+$/, "")); setPhase({ kind: "idle" }); e.target.value = ""; }, [], ); - const toggleWarning = useCallback((value: string) => { - setWarnings((prev) => { - const next = new Set(prev); - next.has(value) ? next.delete(value) : next.add(value); - return next; - }); - }, []); - - // thumbnail handlers - const handleThumbnailSelect = useCallback(() => { - thumbnailInputRef.current?.click(); - }, []); - - const handleThumbnailChange = useCallback( - (e: React.ChangeEvent) => { - const f = e.target.files?.[0] ?? null; - if (f) { - const blob = new Blob([f], { type: f.type }); - setThumbnail(blob); - setThumbnailUrl(URL.createObjectURL(blob)); - } - e.target.value = ""; - }, - [], - ); - - const handleThumbnailRemove = useCallback(() => { - setThumbnail(undefined); - setThumbnailUrl(undefined); - }, []); - - const [chunkSize, setChunkSize] = useState(undefined); - - // ── upload ─────────────────────────────────────────────────────────────── + const agent = gate.state === "ok" ? gate.agent : null; const startUpload = useCallback(async () => { if (!agent || !file) return; @@ -317,7 +783,7 @@ export default function UploadScreen() { message: err instanceof Error ? err.message : String(err), }); } - }, [agent, file, chunkSize]); + }, [agent, file]); const cancelUpload = useCallback(() => { if (pollRef.current) clearTimeout(pollRef.current); @@ -326,384 +792,773 @@ export default function UploadScreen() { setPhase({ kind: "idle" }); }, []); - // ── derived state ───────────────────────────────────────────────────────── - const isUploading = phase.kind === "creating" || phase.kind === "uploading" || phase.kind === "processing"; const canUpload = !!file && (phase.kind === "idle" || phase.kind === "error"); - // ── VOD manager ──────────────────────────────────────────────────────────── - - const [managerMode, setManagerMode] = useState< - "upload" | "videos" | "livestreams" | "drafts" - >("upload"); - const [userVideos, setUserVideos] = useState([]); - const [livestreams, setLivestreams] = useState([]); - const [livestreamsLoading, setLivestreamsLoading] = useState(false); - // 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 - >({}); - // Livestream URI -> draft ats:// URI, recorded when finalizeLivestream - // returns so the row can offer a deep-link to the Drafts tab. - const [finalizedDraftUris, setFinalizedDraftUris] = useState< - Record - >({}); - const [editingVideoUri, setEditingVideoUri] = useState( - undefined, - ); - const [updating, setUpdating] = useState(false); - const [deleting, setDeleting] = useState(false); - - // ── drafts ────────────────────────────────────────────────────────────────── - const [drafts, setDrafts] = useState([]); - const [draftsLoading, setDraftsLoading] = useState(false); - // When editing a draft, holds the draft's ats:// URI so the save handler - // knows to call updateDraft instead of putRecord. - const [editingDraftUri, setEditingDraftUri] = useState( - undefined, - ); - const [draftAction, setDraftAction] = useState< - Record - >({}); - // 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 - >({}); + if (gate.state !== "ok") { + return {null}; + } - const fetchVideos = useCallback(async () => { - if (!agent || !agent.did) return; - try { - const res = await agent.place.stream.media.getVideoList({ - repo: agent.did, - }); - setUserVideos(res.data.videos || []); - } catch (err) { - console.error("Failed to fetch videos", err); - } - }, [agent]); + return ( + + + + + + + + {/* file picker */} + File + + + + + + {/* status */} + {phase.kind !== "idle" && ( + + Status + + {phase.kind === "creating" && ( + + + + + Preparing upload… + + + + + + + )} + {phase.kind === "uploading" && ( + + + + + {phase.pct.toFixed(1)}% —{" "} + {humanBytes(phase.bytesSent)} /{" "} + {humanBytes(phase.bytesTotal)} + + + + + + + )} + {(phase.kind === "processing" || + phase.kind === "publishing") && ( + + + + + {phase.kind === "processing" + ? phase.serverStatus === "processing" + ? `Processing video${phase.progress != null ? ` (${phase.progress}%)` : "…"}` + : "Waiting to process…" + : "Publishing…"} + + + {phase.kind === "processing" && + phase.progress != null ? ( + + + + ) : ( + + + + )} + + )} + {phase.kind === "ready" && ( + + + + Ready to publish + + + )} + {phase.kind === "done" && ( + + + + + Upload complete + + + + navigation.navigate("UploadDrafts" as any) + } + style={{ + flexDirection: "row", + alignItems: "center", + gap: 6, + }} + > + + Find your draft in the Drafts tab to add details and + publish → + + + + )} + {phase.kind === "error" && ( + + + + {phase.message} + + + )} + + + )} - const fetchLivestreams = useCallback(async () => { - if (!agent || !agent.did) return; - setLivestreamsLoading(true); - try { - const res = await agent.com.atproto.repo.listRecords({ - repo: agent.did, - collection: "place.stream.livestream", - limit: 100, - }); - const records = (res.data.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]); + {/* actions */} + + + {canUpload && } + {!file && phase.kind === "idle" && ( + + )} + {isUploading && phase.kind !== "processing" && ( + + )} + {phase.kind === "done" && ( + + )} + + + + + + - const fetchDrafts = useCallback(async () => { - if (!agent || !agent.did) return; - setDraftsLoading(true); - try { - const res = await agent.place.stream.vod.listDrafts({ limit: 100 }); - setDrafts(res.data.drafts || []); - } catch (err) { - console.error("Failed to fetch drafts", err); - } finally { - setDraftsLoading(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(); - 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]); +// ── 2. /upload/video/:tid — unified draft/published editor ────────────────── - useEffect(() => { - // The livestreams tab also needs the videos list to compute which streams - // have already been finalized. - if (managerMode === "videos" || managerMode === "livestreams") { - fetchVideos(); - } - if (managerMode === "livestreams") { - fetchLivestreams(); - } - if (managerMode === "drafts") { - fetchDrafts(); - } - }, [managerMode, fetchVideos, fetchLivestreams, fetchDrafts]); +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(undefined); + const [videoUri_, setVideoUri] = useState(undefined); + const [draftStatus, setDraftStatus] = useState< + PlaceStreamVodDraftVideo.Main["status"] | undefined + >(undefined); + const [meta, setMetaState] = useState(DEFAULT_METADATA); + const [updating, setUpdating] = useState(false); + const [deleting, setDeleting] = useState(false); + const [publishing, setPublishing] = useState(false); + const [error, setError] = useState(undefined); + + const setMeta = useCallback( + ( + updater: (prev: MetadataState) => MetadataState | Partial, + ) => { + setMetaState((prev) => { + const next = updater(prev); + return { ...prev, ...(next as Partial) }; + }); + }, + [], + ); - // 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 Drafts tab. - const finalizeLivestreamRow = useCallback( - async (ls: { uri: string; cid: string; value: any }) => { - if (!agent || !agent.did) return; - setFinalizing((m) => ({ ...m, [ls.uri]: "finalizing" })); + 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.place.stream.media.finalizeLivestream({ - livestream: ls.uri, + const res = await agent.place.stream.vod.getDraft({ uri: duri }); + if (cancelled) return; + const rec = res.data.draft.record as PlaceStreamVodDraftVideo.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.ref as any)?.$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", }); - if (!res.success) throw new Error("finalizeLivestream failed"); - // Record the draft URI so the row can offer a deep-link to the - // Drafts tab (optional affordance). - if (res.data.draftUri) { - setFinalizedDraftUris((m) => ({ - ...m, - [ls.uri]: res.data.draftUri as string, - })); + setLoading(false); + } catch (err: any) { + if (cancelled) return; + // NotFound → fall back to published video + const vuri = videoUri(did, tid); + try { + const existing = await agent.com.atproto.repo.getRecord({ + repo: did, + collection: "place.stream.video", + rkey: tid, + }); + if (cancelled) return; + const rec = existing.data.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.ref as any)?.$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); } - setFinalizing((m) => ({ ...m, [ls.uri]: "done" })); - } catch (err) { - console.error("Failed to finalize livestream", err); - setFinalizing((m) => ({ ...m, [ls.uri]: "error" })); } - }, - [agent], - ); - - const handleSelectVideo = useCallback((video: any) => { - const rec = video.record?.value || video.record || {}; - setEditingVideoUri(video.uri); - setTitle(rec.title || ""); - setDescription(rec.description || ""); - setActivity(rec.activity || undefined); - setTags(rec.tags || []); - setThumbnail(undefined); - setThumbnailUrl(undefined); - // Clear content warnings - const cw = rec.contentWarnings?.warnings || []; - setWarnings(new Set(cw)); - // Set license - const rights = rec.contentRights || {}; - setLicense( - rights.license?.$type || - "place.stream.metadata.contentRights#all-rights-reserved", - ); - setManagerMode("upload"); - }, []); + })(); + return () => { + cancelled = true; + }; + }, [agent, tid]); - const handleUpdateVideo = useCallback(async () => { - if (!agent || !agent.did || !editingVideoUri) return; + const handleSave = useCallback(async () => { + if (!agent || !agent.did) return; setUpdating(true); + setError(undefined); try { - const record: Record = { - $type: "place.stream.video", - title: title.trim(), - source: {}, // placeholder, will be merged with existing - durationMs: 0, // placeholder - }; - // Fetch existing record to preserve source/duration - const existing = await agent.com.atproto.repo.getRecord({ - repo: agent.did, - collection: "place.stream.video", - rkey: editingVideoUri.split("/").pop()!, - }); - const existingRec = existing.data.value as any; - record.source = existingRec.source; - record.durationMs = existingRec.durationMs; - // Preserve the original creation timestamp on edit; fall back for - // records that predate the createdAt field. - record.createdAt = existingRec.createdAt || new Date().toISOString(); - if (description.trim()) record.description = description.trim(); - if (activity) record.activity = activity; - if (tags.length > 0) record.tags = tags; - if (warnings.size > 0) { - record.contentWarnings = { - $type: "place.stream.metadata.contentWarnings", - warnings: [...warnings], + if (mode === "draft" && draftUri_) { + const params: Record = { + uri: draftUri_, + title: meta.title.trim() || "Untitled", }; - } - if ( - license && - license !== "place.stream.metadata.contentRights#all-rights-reserved" - ) { - record.contentRights = { - $type: "place.stream.metadata.contentRights", - license: license, + 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.uploadBlob(meta.thumbnail, { + encoding: meta.thumbnail.type || "image/jpeg", + }); + if (blobRes.success) params.thumb = blobRes.data.blob; + } catch { + // thumbnail failure is non-fatal + } + } + await agent.place.stream.vod.updateDraft(params as any); + } else if (mode === "video") { + // Published video: putRecord. Preserve source/duration/createdAt. + const existing = await agent.com.atproto.repo.getRecord({ + repo: agent.did, + collection: "place.stream.video", + rkey: tid, + }); + const existingRec = existing.data.value as any; + const record: Record = { + $type: "place.stream.video", + title: meta.title.trim(), + source: existingRec.source, + durationMs: existingRec.durationMs, + createdAt: existingRec.createdAt || new Date().toISOString(), }; - } - if (thumbnail) { - try { - const blobRes = await agent.uploadBlob(thumbnail, { - encoding: thumbnail.type || "image/jpeg", - }); - if (blobRes.success) record.thumb = blobRes.data.blob; - } catch { - // thumbnail is non-fatal + 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.uploadBlob(meta.thumbnail, { + encoding: meta.thumbnail.type || "image/jpeg", + }); + if (blobRes.success) record.thumb = blobRes.data.blob; + } catch { + // thumbnail is non-fatal + } } + await agent.com.atproto.repo.putRecord({ + repo: agent.did, + collection: "place.stream.video", + rkey: tid, + record: record as any, + }); } - await agent.com.atproto.repo.putRecord({ - repo: agent.did, - collection: "place.stream.video", - rkey: editingVideoUri.split("/").pop()!, - record: record as any, - }); - setEditingVideoUri(undefined); - setFile(null); - setPhase({ kind: "idle" }); } catch (err) { - console.error("Failed to update video", err); + console.error("Failed to save", err); + setError(err instanceof Error ? err.message : "Failed to save changes."); } finally { setUpdating(false); } - }, [ - agent, - editingVideoUri, - title, - description, - activity, - tags, - warnings, - license, - thumbnail, - ]); - - const handleDeleteVideo = useCallback(async () => { - if (!agent || !editingVideoUri) return; - setDeleting(true); + }, [agent, mode, draftUri_, tid, meta]); + + const handlePublish = useCallback(async () => { + if (!agent || !draftUri_) return; + setPublishing(true); + setError(undefined); try { - await agent.com.atproto.repo.deleteRecord({ - repo: agent.did!, - collection: "place.stream.video", - rkey: editingVideoUri.split("/").pop()!, + const res = await agent.place.stream.vod.publishDraft({ + uri: draftUri_, + }); + // Navigate to the player route for the published video. + const publishedTid = tidFromUri(res.data.videoUri); + navigation.navigate("Video" as any, { + user: agent.did ?? "", + tid: publishedTid, }); - setEditingVideoUri(undefined); - setFile(null); - setPhase({ kind: "idle" }); - setManagerMode("videos"); } catch (err) { - console.error("Failed to delete video", err); + console.error("Failed to publish draft", err); + setError(err instanceof Error ? err.message : "Failed to publish."); } finally { - setDeleting(false); + setPublishing(false); } - }, [agent, editingVideoUri]); - - // ── draft handlers ────────────────────────────────────────────────────────── - - // Load a draft's metadata into the editor form and switch to the upload tab, - // mirroring handleSelectVideo. editingDraftUri tells the save handler to - // call updateDraft instead of putRecord. - const handleSelectDraft = useCallback( - (draft: PlaceStreamVodDraftDefs.DraftView) => { - const rec = draft.record as any; - setEditingDraftUri(draft.uri); - setEditingVideoUri(undefined); - setTitle(rec.title || ""); - setDescription(rec.description || ""); - setActivity(rec.activity || undefined); - setTags(rec.tags || []); - setThumbnail(undefined); - setThumbnailUrl( - rec.thumb - ? `https://cdn.stream.place/thumb/${rec.thumb.ref?.$link || rec.thumb.cid || ""}` - : undefined, - ); - const cw = rec.contentWarnings?.warnings || []; - setWarnings(new Set(cw)); - const rights = rec.contentRights || {}; - setLicense( - rights.license?.$type || - rights.license || - "place.stream.metadata.contentRights#all-rights-reserved", - ); - setManagerMode("upload"); - }, - [], - ); + }, [agent, draftUri_, navigation]); - // Save edits to a draft via updateDraft (not putRecord). The server owns - // source/duration/status; we only send the editable fields. - const handleUpdateDraft = useCallback(async () => { - if (!agent || !editingDraftUri) return; - setUpdating(true); + const handleDelete = useCallback(async () => { + if (!agent || !agent.did) return; + setDeleting(true); + setError(undefined); try { - const params: Record = { - uri: editingDraftUri, - title: title.trim() || "Untitled", - }; - if (description.trim()) params.description = description.trim(); - if (activity) params.activity = activity; - if (tags.length > 0) params.tags = tags; - if (warnings.size > 0) { - params.contentWarnings = { - $type: "place.stream.metadata.contentWarnings", - warnings: [...warnings], - }; - } - if ( - license && - license !== "place.stream.metadata.contentRights#all-rights-reserved" - ) { - params.contentRights = { - $type: "place.stream.metadata.contentRights", - license: license, - }; - } - if (thumbnail) { - try { - const blobRes = await agent.uploadBlob(thumbnail, { - encoding: thumbnail.type || "image/jpeg", - }); - if (blobRes.success) params.thumb = blobRes.data.blob; - } catch { - // thumbnail failure is non-fatal - } + if (mode === "draft" && draftUri_) { + await agent.place.stream.vod.deleteDraft({ uri: draftUri_ }); + } else if (mode === "video") { + await agent.com.atproto.repo.deleteRecord({ + repo: agent.did, + collection: "place.stream.video", + rkey: tid, + }); } - await agent.place.stream.vod.updateDraft(params as any); - setEditingDraftUri(undefined); - setFile(null); - setPhase({ kind: "idle" }); - setManagerMode("drafts"); + navigation.navigate("UploadVideos" as any); } catch (err) { - console.error("Failed to update draft", err); + console.error("Failed to delete", err); + setError(err instanceof Error ? err.message : "Failed to delete."); } finally { - setUpdating(false); + setDeleting(false); } - }, [ - agent, - editingDraftUri, - title, - description, - activity, - tags, - thumbnail, - warnings, - license, - ]); - - // Publish a ready draft. On success the draft is deleted server-side; we - // record the new video URI optimistically and refresh the list. - const handlePublishDraft = useCallback( + }, [agent, mode, draftUri_, tid, navigation]); + + if (gate.state !== "ok") { + return {null}; + } + if (loading) return ; + if (error && !mode) { + return ( + + + + + + + {error} + + + + + ); + } + + return ( + + + + + {/* Editor header */} + + + {mode === "draft" ? "Editing draft" : "Editing video"} + {draftStatus + ? ` · ${draftStatus === "processing" ? "Processing" : draftStatus === "error" ? "Error" : "Ready"}` + : ""} + + + + + + + {error && ( + + + {error} + + + )} + + + + + {/* Right column: actions */} + + + + Actions + + + + {mode === "draft" && draftStatus === "ready" && ( + + )} + {mode === "draft" && draftStatus === "processing" && ( + + + + Processing — publish will be available when ready. + + + )} + {mode === "draft" && draftStatus === "error" && ( + + + Processing failed. You can discard this draft. + + + )} + + + + + + + + + ); +} + +// ── 3. /upload/drafts — drafts list ────────────────────────────────────────── + +export function UploadDraftsScreen() { + const gate = useUploadGate(); + const { theme } = useTheme(); + const navigation = useNavigation(); + + const [drafts, setDrafts] = useState([]); + const [draftsLoading, setDraftsLoading] = useState(false); + const [draftAction, setDraftAction] = useState< + Record + >({}); + // 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 + >({}); + + const agent = gate.state === "ok" ? gate.agent : null; + + const fetchDrafts = useCallback(async () => { + if (!agent) return; + setDraftsLoading(true); + try { + const res = await agent.place.stream.vod.listDrafts({ limit: 100 }); + setDrafts(res.data.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" })); @@ -734,432 +1589,359 @@ export default function UploadScreen() { [agent], ); - // ── render ──────────────────────────────────────────────────────────────── - - // Login gate: uploading needs an account. Wait for auth to resolve, then - // prompt logged-out users with a login button that returns them here. - if (!isReady) { - return ; - } - if (!userProfile) { - return ( - - - - Log in to upload videos - - - You need to be logged in to upload and manage your videos. - - - - - ); - } - - // Beta gate: hold the upload UI until we know the account's access status. - // While we're still resolving it for a logged-in user, show a spinner; if - // they're not granted, swap the whole page for the request-access flow. - // Logged-out users keep the legacy behavior (status stays null) and are - // prompted to log in only when they actually try to upload. - if (agent?.did && betaStatus === null && betaLoading) { - return ; - } - if (betaStatus === "none" || betaStatus === "requested") { - return ( - - ); + if (gate.state !== "ok") { + return {null}; } - const renderTab = ( - mode: "upload" | "videos" | "livestreams" | "drafts", - label: string, - position: "left" | "middle" | "right", - ) => { - const active = managerMode === mode; - const radiusOverride = - position === "left" - ? { borderTopRightRadius: 0, borderBottomRightRadius: 0 } - : position === "right" - ? { borderTopLeftRadius: 0, borderBottomLeftRadius: 0 } - : { borderRadius: 0 }; - return ( - setManagerMode(mode)} - style={[ - zero.px[4], - zero.py[2], - zero.r.lg, - radiusOverride, - active - ? { backgroundColor: theme.colors.primary } - : { - backgroundColor: "transparent", - borderWidth: 1, - borderColor: theme.colors.border, - ...(position === "left" ? {} : { borderLeftWidth: 0 }), - }, - ]} - > - - {label} - - - ); - }; - return ( - {/* Mode toggle */} - - {renderTab("upload", "Upload", "left")} - {renderTab("videos", "My Videos", "middle")} - {renderTab("livestreams", "Livestreams", "middle")} - {renderTab("drafts", "Drafts", "right")} - + - {/* Video list mode */} - {managerMode === "videos" && ( - - {userVideos.length === 0 && ( - - - )} - {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 ( - handleSelectVideo(video)} - style={[ - zero.p[3], - zero.r.md, - { - flexDirection: "row", - gap: 12, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.background, - alignItems: "center", - }, - ]} - > - {thumbUrl ? ( - - ) : ( + + {draftsLoading && drafts.length === 0 && ( + + + + )} + {!draftsLoading && drafts.length === 0 && ( + + + )} + {drafts.map((draft) => { + const rec = draft.record as PlaceStreamVodDraftVideo.Main; + const status = rec.status; + const action = draftAction[draft.uri]; + const publishedUri = publishedFromDrafts[draft.uri]; + return ( + + + + {rec.title || "Untitled"} + + + {rec.createdAt + ? new Date(rec.createdAt).toLocaleString() + : ""} + {" · "} + {status === "processing" + ? "Processing" + : status === "error" + ? `Error: ${rec.error || "unknown"}` + : "Ready"} + + + {publishedUri ? ( + - - )} - - - {rec.title || "Untitled"} - - - {video.viewCounts?.count != null && - `${video.viewCounts.count} views · `} - {rec.durationMs - ? `${Math.round(rec.durationMs / 1000)}s` - : ""} - - - - ); - })} - - )} - - {/* Livestreams list mode */} - {managerMode === "livestreams" && ( - - {livestreamsLoading && livestreams.length === 0 && ( - - - - )} - {!livestreamsLoading && livestreams.length === 0 && ( - - - )} - {livestreams.map((ls: any) => { - const rec = ls.value || {}; - const ended = !!rec.endedAt; - const vodUri = livestreamToVideo.get(ls.uri); - const status = finalizing[ls.uri]; - return ( - + ) : action === "publishing" || action === "deleting" ? ( + - - - {rec.title || "Untitled livestream"} - + gap: 6, + }} + > + - {rec.createdAt - ? new Date(rec.createdAt).toLocaleString() - : ""} - {ended ? " · Ended" : " · Live"} + {action === "publishing" ? "Publishing…" : "Deleting…"} - {vodUri ? ( - - - - - View VOD - - - - ) : status === "finalizing" ? ( - + + - - ) : ( + Publish + - )} - - ); - })} - - )} - - {/* Drafts list mode */} - {managerMode === "drafts" && ( - - {draftsLoading && drafts.length === 0 && ( - - - - )} - {!draftsLoading && drafts.length === 0 && ( - - - )} - {drafts.map((draft) => { - const rec = draft.record as PlaceStreamVodDraftVideo.Main; - const status = rec.status; - const action = draftAction[draft.uri]; - const publishedUri = publishedFromDrafts[draft.uri]; - return ( - - - - {rec.title || "Untitled"} - - - {rec.createdAt - ? new Date(rec.createdAt).toLocaleString() - : ""} - {" · "} - {status === "processing" - ? "Processing" - : status === "error" - ? `Error: ${rec.error || "unknown"}` - : "Ready"} - - {publishedUri ? ( - - - - - View VOD - - - - ) : action === "publishing" || action === "deleting" ? ( + ) : status === "error" ? ( + + ) : ( + + )} + + ); + })} + + + + ); +} + +// ── 4. /upload/livestreams — livestream list + finalize ────────────────────── + +export function UploadLivestreamsScreen() { + const gate = useUploadGate(); + const { theme } = useTheme(); + const navigation = useNavigation(); + + const [livestreams, setLivestreams] = useState([]); + const [livestreamsLoading, setLivestreamsLoading] = useState(false); + const [userVideos, setUserVideos] = useState([]); + // 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 + >({}); + // 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 + >({}); + + const agent = gate.state === "ok" ? gate.agent : null; + + const fetchVideos = useCallback(async () => { + if (!agent || !agent.did) return; + try { + const res = await agent.place.stream.media.getVideoList({ + repo: agent.did, + }); + setUserVideos(res.data.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.com.atproto.repo.listRecords({ + repo: agent.did, + collection: "place.stream.livestream", + limit: 100, + }); + const records = (res.data.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(); + 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.place.stream.media.finalizeLivestream({ + livestream: ls.uri, + }); + if (!res.success) throw new Error("finalizeLivestream failed"); + // Record the draft URI so the row can offer a deep-link to the + // draft editor. + if (res.data.draftUri) { + setFinalizedDraftUris((m) => ({ + ...m, + [ls.uri]: res.data.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 {null}; + } + + return ( + + + + + + {livestreamsLoading && livestreams.length === 0 && ( + + + + )} + {!livestreamsLoading && livestreams.length === 0 && ( + + + )} + {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 ( + + + + {rec.title || "Untitled livestream"} + + + {rec.createdAt + ? new Date(rec.createdAt).toLocaleString() + : ""} + {ended ? " · Ended" : " · Live"} + + + {vodUri ? ( + - - - {action === "publishing" ? "Publishing…" : "Deleting…"} + + + View VOD - ) : status === "ready" ? ( - - - - - - ) : status === "error" ? ( - - ) : ( - - )} - - ); - })} - - )} - - {/* Upload/Edit mode */} - {managerMode === "upload" && ( - <> - {(editingVideoUri || editingDraftUri) && ( - - - {editingDraftUri ? "Editing draft" : "Editing video"} - - - {editingVideoUri && ( - - )} + + ) : status === "finalizing" ? ( + + + + Finalizing… + + + ) : status === "done" ? ( { - setEditingVideoUri(undefined); - setEditingDraftUri(undefined); - setPhase({ kind: "idle" }); - setFile(null); - setTitle(""); - setDescription(""); - setActivity(undefined); - setTags([]); - setTagInput(""); - setThumbnail(undefined); - setThumbnailUrl(undefined); - setWarnings(new Set()); - setLicense( - "place.stream.metadata.contentRights#all-rights-reserved", - ); + if (draftU) { + navigation.navigate("UploadVideo" as any, { + tid: tidFromUri(draftU), + }); + } else { + navigation.navigate("UploadDrafts" as any); + } + }} + style={{ + flexDirection: "row", + alignItems: "center", + gap: 6, }} > - + + + Edit draft → + - + ) : !ended ? ( + + + + ) : ( + + )} - )} - - {/* ── left column: metadata ────────────────────────────────────── */} - - - {/* title + description */} - - Details - - - - Title - - - - - - - Description - - - - - + ); + })} + + + + ); +} - {/* activity */} - - Activity - - - - - - +// ── 5. /upload/videos — My Videos list ─────────────────────────────────────── - {/* tags */} - - Tags - - - {tags.length > 0 && ( - - {tags.map((tag) => ( - - setTags(tags.filter((t) => t !== tag)) - } - style={{ - flexDirection: "row", - alignItems: "center", - backgroundColor: theme.colors.primary + "22", - borderRadius: 12, - paddingHorizontal: 10, - paddingVertical: 3, - }} - > - - {tag} - - - × - - - ))} - - )} - {tags.length < 10 && ( - - setTagInput(v.replace(/[^a-zA-Z0-9:]/g, "")) - } - placeholder="Add tag, press Enter" - placeholderTextColor={theme.colors.mutedForeground} - returnKeyType="done" - onSubmitEditing={() => { - const t = tagInput.trim(); - if (t && !tags.includes(t)) setTags([...tags, t]); - setTagInput(""); - }} - style={inputStyle(theme)} - /> - )} - - - +export function UploadVideosScreen() { + const gate = useUploadGate(); + const { theme } = useTheme(); + const navigation = useNavigation(); - {/* content warnings */} - - Content Warnings - - - {CONTENT_WARNINGS.map((cw) => ( - - toggleWarning(cw.value)} - label={cw.label} - size="sm" - /> - - ))} - - - + const [userVideos, setUserVideos] = useState([]); - {/* license */} - - License - - - - - - - {/* status */} - {phase.kind !== "idle" && ( - - Status - - {phase.kind === "creating" && ( - - - - - Preparing upload… - - - - - - - )} - {phase.kind === "uploading" && ( - - - - - {phase.pct.toFixed(1)}% —{" "} - {humanBytes(phase.bytesSent)} /{" "} - {humanBytes(phase.bytesTotal)} - - - - - - - )} - {(phase.kind === "processing" || - phase.kind === "publishing") && ( - - - - - {phase.kind === "processing" - ? phase.serverStatus === "processing" - ? `Processing video${phase.progress != null ? ` (${phase.progress}%)` : "…"}` - : "Waiting to process…" - : "Publishing…"} - - - {phase.kind === "processing" && - phase.progress != null ? ( - - - - ) : ( - - - - )} - - )} - {phase.kind === "ready" && ( - - - - Ready to publish - - - )} - {phase.kind === "done" && ( - - - - - Upload complete - - - setManagerMode("drafts")} - style={{ - flexDirection: "row", - alignItems: "center", - gap: 6, - }} - > - - Find your draft in the Drafts tab to add - details and publish → - - - - )} - {phase.kind === "error" && ( - - - - {phase.message} - - - )} - - - )} - - {/* actions */} - - - {editingDraftUri ? ( - - ) : editingVideoUri ? ( - - ) : ( - <> - {canUpload && ( - - )} - {!file && phase.kind === "idle" && ( - - )} - {isUploading && phase.kind !== "processing" && ( - - )} - {phase.kind === "done" && ( - - )} - - )} - - - - - - )} + const fetchVideos = useCallback(async () => { + if (!agent || !agent.did) return; + try { + const res = await agent.place.stream.media.getVideoList({ + repo: agent.did, + }); + setUserVideos(res.data.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 {null}; + } + + return ( + + + + + + {userVideos.length === 0 && ( + + - - )} + )} + {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 ( + + 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 ? ( + + ) : ( + + + )} + + + {rec.title || "Untitled"} + + + {video.viewCounts?.count != null && + `${video.viewCounts.count} views · `} + {rec.durationMs + ? `${Math.round(rec.durationMs / 1000)}s` + : ""} + + + + ); + })} + - - ); } - -// ── style helpers ───────────────────────────────────────────────────────────── - -function inputStyle(theme: any) { - return { - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: 8, - padding: 8, - color: theme.colors.foreground, - backgroundColor: theme.colors.background, - }; -} diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx index b3f7cc4af..47aecda52 100644 --- a/js/app/src/shell.tsx +++ b/js/app/src/shell.tsx @@ -66,7 +66,12 @@ import PopoutLivestream from "src/screens/popout-livestream"; import PopoutMultistream from "src/screens/popout-multistream"; import PopoutStreamMonitor from "src/screens/popout-stream-monitor"; import SupportScreen from "src/screens/support"; -import UploadScreen from "src/screens/upload"; +import UploadScreen, { + UploadDraftsScreen, + UploadLivestreamsScreen, + UploadVideoScreen, + UploadVideosScreen, +} from "src/screens/upload"; import VideoScreen from "src/screens/video"; import VideoListScreen from "src/screens/video-list"; import VodScreen from "src/screens/vod"; @@ -219,6 +224,34 @@ function HomeNavigator() { options={{ title: "Upload Video", ...headerScreenOptions }} /> )} + {!isNative && ( + + )} + {!isNative && ( + + )} + {!isNative && ( + + )} + {!isNative && ( + + )} ); }