// Upload state machine for VOD uploads. import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { place } from "streamplace"; import * as tus from "tus-js-client"; import { usePDSAgent } from "../lib/store/hooks"; // ── types ──────────────────────────────────────────────────────────────────── type TrackRef = { uri: string; cid: string }; export type UploadPhase = | { kind: "idle" } | { kind: "creating" } | { kind: "uploading"; pct: number; bytesSent: number; bytesTotal: number } | { kind: "processing"; uploadId: string; serverStatus?: "pending" | "processing"; progress?: number; } | { kind: "ready"; uploadId: string; tracks: TrackRef[]; durationMs: number } | { kind: "publishing" } | { kind: "done"; videoUri: string } | { kind: "error"; message: string }; export interface UploadMetadata { title: string; description: string; tags: string[]; warnings: Set; license: string; thumbnail?: Blob; } const POLL_INTERVAL_MS = 3000; const MAX_POLL_ATTEMPTS = 100; // ~5 minutes at 3s intervals // ── helpers ─────────────────────────────────────────────────────────────────── export 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. export async function validateVideoFile(file: File): Promise { const buf = await file.slice(0, 12).arrayBuffer(); const b = new Uint8Array(buf); 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 (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 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)."; } // ── hook ────────────────────────────────────────────────────────────────────── export function useUpload() { const agent = usePDSAgent(); const { t } = useTranslation(); const uploadRef = useRef(null); const pollRef = useRef | null>(null); // Generation token: each polling run gets a unique ID. After every // await, the poller checks whether its generation is still current. // Cancellation bumps the generation so stale pollers stop touching // state and don't reschedule. const pollGenRef = useRef(0); const [phase, setPhase] = useState({ kind: "idle" }); const [file, setFile] = useState(null); // metadata form; always editable const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [tags, setTags] = useState([]); const [tagInput, setTagInput] = useState(""); const [thumbnail, setThumbnail] = useState(undefined); const [thumbnailUrl, setThumbnailUrl] = useState( undefined, ); const [warnings, setWarnings] = useState>(new Set()); const [license, setLicense] = useState( "place.stream.metadata.contentRights#all-rights-reserved", ); // cleanup on unmount useEffect(() => { return () => { pollGenRef.current++; if (pollRef.current) clearTimeout(pollRef.current); uploadRef.current?.abort(); }; }, []); // clean up blob URLs when thumbnail changes or unmounts useEffect(() => { return () => { if (thumbnailUrl) URL.revokeObjectURL(thumbnailUrl); }; }, [thumbnailUrl]); // ── file selection ──────────────────────────────────────────────────────── const selectFile = useCallback((f: File | null) => { setFile(f); if (f) setTitle((t) => t || f.name.replace(/\.[^.]+$/, "")); setPhase({ kind: "idle" }); }, []); // ── thumbnail ───────────────────────────────────────────────────────────── const selectThumbnail = useCallback((f: File | null) => { if (f) { const blob = new Blob([f], { type: f.type }); setThumbnail(blob); setThumbnailUrl(URL.createObjectURL(blob)); } }, []); const removeThumbnail = useCallback(() => { setThumbnail(undefined); setThumbnailUrl(undefined); }, []); // ── tags ────────────────────────────────────────────────────────────────── const addTag = useCallback( (tag: string) => { const t = tag.trim(); if (t && !tags.includes(t) && tags.length < 10) { setTags([...tags, t]); } setTagInput(""); }, [tags], ); const removeTag = useCallback((tag: string) => { setTags((prev) => prev.filter((t) => t !== tag)); }, []); // ── content warnings ────────────────────────────────────────────────────── const toggleWarning = useCallback((value: string) => { setWarnings((prev) => { const next = new Set(prev); next.has(value) ? next.delete(value) : next.add(value); return next; }); }, []); // ── polling ─────────────────────────────────────────────────────────────── const pollStatus = useCallback( (uploadId: string) => { if (!agent) return; const gen = ++pollGenRef.current; let attempts = 0; const check = async () => { if (gen !== pollGenRef.current) return; attempts += 1; if (attempts > MAX_POLL_ATTEMPTS) { if (gen !== pollGenRef.current) return; setPhase({ kind: "error", message: t("upload-error-timeout"), }); return; } try { const res = await agent.client.call( place.stream.media.getUploadStatus, { uploadId, }, ); if (gen !== pollGenRef.current) return; const data = res; if (data.status === "done" && data.tracks) { setPhase({ kind: "ready", uploadId, tracks: data.tracks, durationMs: data.durationMs ?? 0, }); return; } if (data.status === "error") { setPhase({ kind: "error", message: data.error ?? t("upload-error-processing"), }); return; } setPhase({ kind: "processing", uploadId, serverStatus: data.status as "pending" | "processing", progress: data.progress, }); pollRef.current = setTimeout(check, POLL_INTERVAL_MS); } catch { if (gen !== pollGenRef.current) return; pollRef.current = setTimeout(check, POLL_INTERVAL_MS); } }; check(); }, [agent, t], ); // ── upload ──────────────────────────────────────────────────────────────── const startUpload = useCallback(async () => { if (!agent || !file) return; if (!agent.did) { setPhase({ kind: "error", message: t("upload-error-not-logged-in") }); return; } const validationError = await validateVideoFile(file); if (validationError) { setPhase({ kind: "error", message: t("upload-error-format") }); return; } const mimeType = file.type.startsWith("video/") ? file.type : "video/mp4"; setPhase({ kind: "creating" }); try { const res = await agent.client.call(place.stream.media.createUpload, { size: file.size, mimeType, filename: file.name, }); const { uploadUrl, uploadToken, uploadId } = res; await new Promise((resolve, reject) => { const upload = new tus.Upload(file, { uploadUrl, retryDelays: [0, 1000, 3000, 5000], headers: { Authorization: `Bearer ${uploadToken}` }, metadata: { filename: file.name, filetype: file.type }, onError: reject, onProgress(bytesSent, bytesTotal) { setPhase({ kind: "uploading", pct: bytesTotal > 0 ? (bytesSent / bytesTotal) * 100 : 0, bytesSent, bytesTotal, }); }, onSuccess: () => resolve(), }); uploadRef.current = upload; upload.start(); }); uploadRef.current = null; setPhase({ kind: "processing", uploadId }); pollStatus(uploadId); } catch (err) { uploadRef.current = null; setPhase({ kind: "error", message: err instanceof Error ? err.message : String(err), }); } }, [agent, file, pollStatus, t]); const cancelUpload = useCallback(() => { pollGenRef.current++; if (pollRef.current) clearTimeout(pollRef.current); uploadRef.current?.abort(); uploadRef.current = null; setPhase({ kind: "idle" }); }, []); // ── publish ─────────────────────────────────────────────────────────────── const publish = useCallback(async () => { if (phase.kind !== "ready" || !agent || !agent.did) return; setPhase({ kind: "publishing" }); try { const { tracks, durationMs } = phase; const record = { $type: "place.stream.video", title: title.trim() || file?.name || t("upload-title-untitled"), createdAt: new Date().toISOString() as any, durationMs, source: { $type: "place.stream.media.defs#sourceTracks", tracks: tracks.map((t) => ({ $type: "com.atproto.repo.strongRef", uri: t.uri, cid: t.cid, })) as any, }, } as any; if (description.trim()) record.description = description.trim(); if (tags.length > 0) record.tags = tags; if (warnings.size > 0) { const cw: Record = {}; for (const w of warnings) { const key = w.split("#")[1]; if (key) cw[key] = true; } record.contentWarnings = { $type: "place.stream.metadata.contentWarnings", ...cw, } as any; } if ( license && license !== "place.stream.metadata.contentRights#all-rights-reserved" ) { const licenseKey = license.split("#")[1]; record.contentRights = { $type: "place.stream.metadata.contentRights", license: { $type: license } as any, [licenseKey]: { $type: license } as any, } as any; } if (thumbnail) { try { const blobRes = await agent.uploadBlob(thumbnail, { encoding: thumbnail.type || "image/jpeg", }); if (blobRes.success) record.thumb = blobRes.data.blob as any; } catch { // thumbnail failure is non-fatal } } const res = await agent.client.call(place.stream.media.publishVideo, { uploadId: phase.uploadId, record, }); setPhase({ kind: "done", videoUri: res.uri }); } catch (err) { setPhase({ kind: "error", message: err instanceof Error ? err.message : String(err), }); } }, [ phase, agent, title, description, tags, thumbnail, warnings, license, file, t, ]); // ── derived state ───────────────────────────────────────────────────────── const isUploading = phase.kind === "creating" || phase.kind === "uploading" || phase.kind === "processing"; const isPublishing = phase.kind === "publishing"; const canUpload = !!file && (phase.kind === "idle" || phase.kind === "error"); const canPublish = phase.kind === "ready" && !!title.trim(); // ── direct setters (for edit mode) ───────────────────────────────────── const setTagsDirectly = useCallback((newTags: string[]) => { setTags(newTags); }, []); const setWarningsDirectly = useCallback((newWarnings: Set) => { setWarnings(newWarnings); }, []); // ── reset ───────────────────────────────────────────────────────────────── const reset = useCallback(() => { setFile(null); setTitle(""); setDescription(""); setTags([]); setTagInput(""); setThumbnail(undefined); setThumbnailUrl(undefined); setWarnings(new Set()); setLicense("place.stream.metadata.contentRights#all-rights-reserved"); setPhase({ kind: "idle" }); }, []); return { // state phase, file, title, description, tags, tagInput, thumbnail, thumbnailUrl, warnings, license, // derived isUploading, isPublishing, canUpload, canPublish, // actions selectFile, selectThumbnail, removeThumbnail, setTitle, setDescription, setTagInput, addTag, removeTag, toggleWarning, setTagsDirectly, setWarningsDirectly, setLicense, startUpload, cancelUpload, publish, reset, }; }