diff --git a/js/app/components/upload/upload-progress-indicator.tsx b/js/app/components/upload/upload-progress-indicator.tsx new file mode 100644 index 00000000..ac6e5b97 --- /dev/null +++ b/js/app/components/upload/upload-progress-indicator.tsx @@ -0,0 +1,144 @@ +import { Text, useTheme } from "@streamplace/components"; +import { AlertCircle, ArrowUp, CheckCircle2, X } from "lucide-react-native"; +import { useSyncExternalStore } from "react"; +import { Pressable, View } from "react-native"; +import { + cancelUpload, + dismissUpload, + getUploads, + subscribeUploads, + UploadJob, +} from "utils/upload-manager"; + +// Floating upload status card, pinned to the lower-right corner of the app +// shell. Lives outside the navigators so it keeps rendering progress while +// the user moves between screens; the uploads themselves live in +// utils/upload-manager. + +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`; +} + +function UploadRow({ job }: { job: UploadJob }) { + const { theme } = useTheme(); + const pct = job.bytesTotal > 0 ? (job.bytesSent / job.bytesTotal) * 100 : 0; + + return ( + + + {job.status === "uploading" && ( + + )} + {job.status === "done" && ( + + )} + {job.status === "error" && ( + + )} + + {job.filename} + + + job.status === "uploading" + ? cancelUpload(job.id) + : dismissUpload(job.id) + } + hitSlop={8} + > + + + + {job.status === "uploading" && ( + <> + + + + + {pct.toFixed(1)}% — {humanBytes(job.bytesSent)} /{" "} + {humanBytes(job.bytesTotal)} + + + )} + {job.status === "done" && ( + + Upload complete — processing continues on the server + + )} + {job.status === "error" && ( + + {job.error || "Upload failed"} + + )} + + ); +} + +export default function UploadProgressIndicator() { + const { theme } = useTheme(); + const jobs = useSyncExternalStore(subscribeUploads, getUploads, getUploads); + + if (jobs.length === 0) return null; + + return ( + + + {jobs.map((job) => ( + + ))} + + + ); +} diff --git a/js/app/src/screens/upload.tsx b/js/app/src/screens/upload.tsx index b43c529f..834a76c3 100644 --- a/js/app/src/screens/upload.tsx +++ b/js/app/src/screens/upload.tsx @@ -45,7 +45,7 @@ import { import { useStore } from "store"; import { useIsReady, useUserProfile } from "store/hooks"; import { place } from "streamplace"; -import * as tus from "tus-js-client"; +import * as uploadManager from "utils/upload-manager"; // ── types ──────────────────────────────────────────────────────────────────── @@ -653,8 +653,6 @@ export default function UploadScreen() { const navigation = useNavigation(); 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); const [phase, setPhase] = useState({ kind: "idle" }); @@ -687,14 +685,6 @@ export default function UploadScreen() { } }, [phase.kind, processingAnim]); - // cleanup on unmount - useEffect(() => { - return () => { - if (pollRef.current) clearTimeout(pollRef.current); - uploadRef.current?.abort(); - }; - }, []); - const pickFile = useCallback(() => fileInputRef.current?.click(), []); const handleFileChange = useCallback( @@ -742,51 +732,16 @@ export default function UploadScreen() { }); 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 }); - - // The TUS upload runs to completion here; the draft (already navigated - // to) will flip to 'ready' server-side when processing finishes. - await new Promise((resolve, reject) => { - let retried = false; - const params: tus.UploadOptions = { - uploadUrl, - retryDelays: [0, 1000, 3000, 5000], - headers: { Authorization: `Bearer ${uploadToken}` }, - metadata: { filename: file.name, filetype: file.type }, - onError: (err) => { - if (!retried) { - retried = true; - // <1mb for default nginx proxy settings - params.chunkSize = 800000; - doTry(); - } else { - console.log(err); - reject(err); - } - }, - onProgress(bytesSent, bytesTotal) { - // Progress isn't shown on the bare upload screen anymore (we've - // navigated away to the editor); the editor surfaces the draft's - // processing status instead. - }, - onSuccess: () => resolve(), - }; - const doTry = () => { - const upload = new tus.Upload(file, params); - uploadRef.current = upload; - upload.start(); - }; - doTry(); - }); - - uploadRef.current = null; - // No phase change here: we've already navigated to the draft editor, - // which polls/reloads the draft and reflects the 'ready' state when - // processing completes. } catch (err) { - uploadRef.current = null; setPhase({ kind: "error", message: err instanceof Error ? err.message : String(err), @@ -795,9 +750,6 @@ export default function UploadScreen() { }, [agent, file, navigation]); const cancelUpload = useCallback(() => { - if (pollRef.current) clearTimeout(pollRef.current); - uploadRef.current?.abort(); - uploadRef.current = null; setPhase({ kind: "idle" }); }, []); diff --git a/js/app/src/shell.tsx b/js/app/src/shell.tsx index 47aecda5..ed990807 100644 --- a/js/app/src/shell.tsx +++ b/js/app/src/shell.tsx @@ -38,6 +38,7 @@ import RecommendationsManager from "components/settings/recommendations-manager" import { StreamingCategorySettings } from "components/settings/streaming-category-settings"; import WebhookManager from "components/settings/webhook-manager"; import { SidebarOverlay } from "components/sidebar/sidebar-overlay"; +import UploadProgressIndicator from "components/upload/upload-progress-indicator"; import { useBlueskyNotifications } from "hooks/useBlueskyNotifications"; import { useLiveUser } from "hooks/useLiveUser"; import usePlatform from "hooks/usePlatform"; @@ -791,6 +792,7 @@ export default function Shell() { loginAction(pdsHost, openLoginLink); }} /> + ); } diff --git a/js/app/utils/upload-manager.ts b/js/app/utils/upload-manager.ts new file mode 100644 index 00000000..97cebf4c --- /dev/null +++ b/js/app/utils/upload-manager.ts @@ -0,0 +1,137 @@ +import * as tus from "tus-js-client"; + +// Module-level upload manager. TUS uploads live here — outside the React +// tree — so navigating away from the upload screen (which unmounts it) no +// longer aborts an in-flight upload. The floating UploadProgressIndicator in +// the app shell subscribes to this store and renders progress until every +// upload finishes. + +export type UploadJob = { + id: string; + tid: string; + filename: string; + status: "uploading" | "done" | "error"; + bytesSent: number; + bytesTotal: number; + error?: string; +}; + +// How long a completed upload lingers in the indicator before auto-dismissing. +const DONE_LINGER_MS = 4000; + +let jobs: UploadJob[] = []; +let nextId = 1; +const listeners = new Set<() => void>(); +const uploads = new Map(); + +function emit() { + listeners.forEach((l) => l()); +} + +function patchJob(id: string, patch: Partial) { + jobs = jobs.map((j) => (j.id === id ? { ...j, ...patch } : j)); + emit(); +} + +// Warn before the tab closes while uploads are still running (web only). +function beforeUnload(e: BeforeUnloadEvent) { + e.preventDefault(); +} + +function syncBeforeUnload() { + if (typeof window === "undefined" || !window.addEventListener) return; + if (jobs.some((j) => j.status === "uploading")) { + window.addEventListener("beforeunload", beforeUnload); + } else { + window.removeEventListener("beforeunload", beforeUnload); + } +} + +export function subscribeUploads(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getUploads(): UploadJob[] { + return jobs; +} + +export function startUpload({ + file, + uploadUrl, + uploadToken, + tid, +}: { + file: File; + uploadUrl: string; + uploadToken: string; + tid: string; +}): string { + const id = `upload-${nextId++}`; + jobs = [ + ...jobs, + { + id, + tid, + filename: file.name, + status: "uploading", + bytesSent: 0, + bytesTotal: file.size, + }, + ]; + emit(); + syncBeforeUnload(); + + let retried = false; + const params: tus.UploadOptions = { + uploadUrl, + retryDelays: [0, 1000, 3000, 5000], + headers: { Authorization: `Bearer ${uploadToken}` }, + metadata: { filename: file.name, filetype: file.type }, + onError: (err) => { + if (!retried) { + retried = true; + // <1mb for default nginx proxy settings + params.chunkSize = 800000; + doTry(); + } else { + console.error("upload failed", err); + uploads.delete(id); + patchJob(id, { + status: "error", + error: err instanceof Error ? err.message : String(err), + }); + syncBeforeUnload(); + } + }, + onProgress: (bytesSent, bytesTotal) => { + patchJob(id, { bytesSent, bytesTotal }); + }, + onSuccess: () => { + uploads.delete(id); + patchJob(id, { status: "done", bytesSent: file.size }); + syncBeforeUnload(); + setTimeout(() => dismissUpload(id), DONE_LINGER_MS); + }, + }; + const doTry = () => { + const upload = new tus.Upload(file, params); + uploads.set(id, upload); + upload.start(); + }; + doTry(); + return id; +} + +export function cancelUpload(id: string) { + uploads.get(id)?.abort(); + uploads.delete(id); + dismissUpload(id); + syncBeforeUnload(); +} + +export function dismissUpload(id: string) { + if (!jobs.some((j) => j.id === id)) return; + jobs = jobs.filter((j) => j.id !== id); + emit(); +}