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();
+}