From 102f44d5e7a03d8ebbd81cbec1c82d4ce4bf72cb Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 18 Aug 2025 16:38:06 +0800 Subject: [PATCH 01/36] upload progress API endpoints --- apps/web/app/api/desktop/[...route]/video.ts | 48 ++++++++++++++++++- .../s/[videoId]/_components/ShareHeader.tsx | 34 ++++++++++++- .../web/app/s/[videoId]/_components/server.ts | 29 +++++++++++ packages/database/schema.ts | 8 ++++ 4 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/s/[videoId]/_components/server.ts diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 1567ce059c..77465f47f7 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -2,7 +2,7 @@ import { db } from "@cap/database"; import { sendEmail } from "@cap/database/emails/config"; import { FirstShareableLink } from "@cap/database/emails/first-shareable-link"; import { nanoId } from "@cap/database/helpers"; -import { s3Buckets, videos } from "@cap/database/schema"; +import { s3Buckets, uploads, videos } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { zValidator } from "@hono/zod-validator"; import { and, count, eq } from "drizzle-orm"; @@ -108,6 +108,10 @@ app.get( }, }); + await db().insert(uploads).values({ + videoId: idToUse, + }); + if (buildEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production") await dub().links.create({ url: `${serverEnv().WEB_URL}/s/${idToUse}`, @@ -192,6 +196,8 @@ app.delete( { status: 404 }, ); + await db().delete(uploads).where(eq(uploads.videoId, videoId)); + await db() .delete(videos) .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); @@ -216,3 +222,43 @@ app.delete( } }, ); + +app.post( + "/progress", + zValidator("query", z.object({ videoId: z.string(), progress: z.number() })), + async (c) => { + const { videoId, progress } = c.req.valid("query"); + const user = c.get("user"); + + try { + const video = await db() + .select({ id: videos.id }) + .from(videos) + .where(and(eq(videos.id, videoId), eq(videos.ownerId, user.id))); + if (!video) + return c.json( + { error: true, message: "Video not found" }, + { status: 404 }, + ); + + await db() + .insert(uploads) + .values({ + videoId, + progress, + updatedAt: new Date(), + }) + .onDuplicateKeyUpdate({ + set: { + progress, + updatedAt: new Date(), + }, + }); + + return c.json(true); + } catch (error) { + console.error("Error in progress update endpoint:", error); + return c.json({ error: "Internal server error" }, { status: 500 }); + } + }, +); diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 7aa36a2402..d8ce00d284 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -1,7 +1,7 @@ "use client"; import type { userSelectProps } from "@cap/database/auth/session"; -import type { videos } from "@cap/database/schema"; +import { type videos } from "@cap/database/schema"; import { buildEnv } from "@cap/env"; import { Button } from "@cap/ui"; import { userIsPro } from "@cap/utils"; @@ -11,7 +11,7 @@ import clsx from "clsx"; import { Copy, Globe2 } from "lucide-react"; import moment from "moment"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; import { editTitle } from "@/actions/videos/edit-title"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; @@ -19,6 +19,9 @@ import { SharingDialog } from "@/app/(org)/dashboard/caps/components/SharingDial import type { Spaces } from "@/app/(org)/dashboard/dashboard-data"; import { UpgradeModal } from "@/components/UpgradeModal"; import { usePublicEnv } from "@/utils/public-env"; +import { db } from "@cap/database"; +import { useQuery } from "@tanstack/react-query"; +import { getUploadProgress } from "./server"; export const ShareHeader = ({ data, @@ -211,6 +214,10 @@ export const ShareHeader = ({

{moment(data.createdAt).fromNow()}

+ + + + {user !== null && ( @@ -269,3 +276,26 @@ export const ShareHeader = ({ ); }; + +const fiveMinutes = 5 * 60 * 1000; +function UploadProgress({ videoId }: { videoId: string }) { + const result = useQuery({ + queryKey: ["uploadProgress", videoId], + queryFn: () => getUploadProgress({ videoId }), + refetchInterval: 3000, + }); + if (!result.data) return null; + + const hasUploadFailed = + Date.now() - new Date(result.data.updatedAt).getTime() > fiveMinutes; + + return ( +

+ {hasUploadFailed ? ( + Upload failed + ) : ( + {result.data?.progress}% + )} +

+ ); +} diff --git a/apps/web/app/s/[videoId]/_components/server.ts b/apps/web/app/s/[videoId]/_components/server.ts new file mode 100644 index 0000000000..eb8a97ff1b --- /dev/null +++ b/apps/web/app/s/[videoId]/_components/server.ts @@ -0,0 +1,29 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import * as Db from "@cap/database/schema"; +import * as Dz from "drizzle-orm"; + +export async function getUploadProgress({ videoId }: { videoId: string }) { + const user = await getCurrentUser(); + if (!user || !user.activeOrganizationId) + throw new Error("Unauthorized or no active organization"); + + const [result] = await db() + .select({ + progress: Db.uploads.progress, + startedAt: Db.uploads.startedAt, + updatedAt: Db.uploads.updatedAt, + }) + .from(Db.uploads) + .innerJoin(Db.videos, Dz.eq(Db.uploads.videoId, Db.videos.id)) + .where( + Dz.and( + Dz.eq(Db.uploads.videoId, videoId), + Dz.eq(Db.videos.ownerId, user.id), + ), + ); + + return result || null; +} diff --git a/packages/database/schema.ts b/packages/database/schema.ts index ba1f70f5d2..12d473dd2c 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -10,6 +10,7 @@ import { mysqlTable, text, timestamp, + tinyint, uniqueIndex, varchar, } from "drizzle-orm/mysql-core"; @@ -628,3 +629,10 @@ export const foldersRelations = relations(folders, ({ one, many }) => ({ childFolders: many(folders, { relationName: "parentChild" }), videos: many(videos), })); + +export const uploads = mysqlTable("uploads", { + videoId: nanoId("video_id").primaryKey().notNull(), + progress: tinyint("progress").notNull().default(0), + startedAt: timestamp("started_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); From 4d999bd6d461d457cf73edc55229f15db12508b1 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 18 Aug 2025 16:47:35 +0800 Subject: [PATCH 02/36] avoid race conditions --- apps/web/app/api/desktop/[...route]/video.ts | 43 ++++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 77465f47f7..e3f4bd5479 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -5,7 +5,7 @@ import { nanoId } from "@cap/database/helpers"; import { s3Buckets, uploads, videos } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { zValidator } from "@hono/zod-validator"; -import { and, count, eq } from "drizzle-orm"; +import { and, count, eq, gt, gte, lt } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { dub } from "@/utils/dub"; @@ -225,9 +225,18 @@ app.delete( app.post( "/progress", - zValidator("query", z.object({ videoId: z.string(), progress: z.number() })), + zValidator( + "query", + z.object({ + videoId: z.string(), + progress: z.number(), + // We get this from the client so we can avoid race conditions. + // Eg. If this value is older than the value in the DB, we ignore it. + updatedAt: z.date({ coerce: true }), + }), + ), async (c) => { - const { videoId, progress } = c.req.valid("query"); + const { videoId, progress, updatedAt } = c.req.valid("query"); const user = c.get("user"); try { @@ -241,19 +250,27 @@ app.post( { status: 404 }, ); - await db() - .insert(uploads) - .values({ - videoId, + const result = await db() + .update(uploads) + .set({ progress, - updatedAt: new Date(), + updatedAt, }) - .onDuplicateKeyUpdate({ - set: { - progress, - updatedAt: new Date(), - }, + .where( + and(eq(uploads.videoId, videoId), lt(uploads.updatedAt, updatedAt)), + ); + + if (result.rowsAffected == 0) { + await db().insert(uploads).values({ + videoId, + progress, + updatedAt, }); + } + + if (progress == 100) { + // TODO: Remove from DB + } return c.json(true); } catch (error) { From 0aa42a6773d6e29768c0981aec90c3d406573a4e Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 18 Aug 2025 17:40:46 +0800 Subject: [PATCH 03/36] wip --- apps/desktop/src-tauri/src/upload.rs | 64 +++++++++++++++++-- apps/web/app/api/desktop/[...route]/video.ts | 38 ++++++----- .../app/api/upload/[...route]/multipart.ts | 8 ++- .../s/[videoId]/_components/ShareHeader.tsx | 7 +- .../web/app/s/[videoId]/_components/server.ts | 13 ++-- packages/database/schema.ts | 5 +- 6 files changed, 102 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 303461d615..4cdadca690 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -3,6 +3,7 @@ use crate::web_api::ManagerExt; use crate::{UploadProgress, VideoUploadInfo}; use cap_utils::spawn_actor; +use chrono; use flume::Receiver; use futures::StreamExt; use image::ImageReader; @@ -138,6 +139,47 @@ pub struct UploadedImage { // pub config: S3UploadMeta, // } +// Helper function to send progress updates to the backend API +fn send_progress_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { + let progress_percent = (uploaded as f64 / total as f64 * 100.0).round() as u32; + + println!( + "SENDING PROGRESS UPDATE {:?} {:?} {:?}", + video_id, + progress_percent, + progress_percent % 5 != 0 && progress_percent != 100 + ); + + // Don't spam the API with too many updates - only send every 5% or at completion + if progress_percent % 5 != 0 && progress_percent != 100 { + return; + } + + let updated_at = chrono::Utc::now().to_rfc3339(); + + let app = app.clone(); + tokio::spawn(async move { + let response = app + .authed_api_request("/api/desktop/video/progress", |client, url| { + client.post(url).json(&json!({ + "videoId": video_id, + "uploaded": uploaded, + "total": total, + "updatedAt": updated_at + })) + }) + .await; + + if let Ok(ref resp) = response + && !resp.status().is_success() + { + error!("Failed to send progress update: {}", resp.status()); + } else if let Err(e) = response { + error!("Failed to send progress update: {}", e); + } + }); +} + pub async fn upload_video( app: &AppHandle, video_id: String, @@ -180,16 +222,16 @@ pub async fn upload_video( let mut bytes_uploaded = 0; let progress_stream = reader_stream.inspect({ let app = app.clone(); + let video_id = video_id.clone(); move |chunk| { - if bytes_uploaded > 0 { - let _ = UploadProgress { - progress: bytes_uploaded as f64 / total_size as f64, - } - .emit(&app); - } - if let Ok(chunk) = chunk { bytes_uploaded += chunk.len(); + let progress = bytes_uploaded as f64 / total_size as f64; + + // Emit local progress event for UI + let _ = UploadProgress { progress }.emit(&app); + + send_progress_update(&app, video_id.clone(), bytes_uploaded as u64, total_size); } } }); @@ -222,6 +264,9 @@ pub async fn upload_video( if response.status().is_success() { println!("Video uploaded successfully"); + // Send final progress update + send_progress_update(&app, video_id.clone(), total_size, total_size); + if let Some(Ok(screenshot_response)) = screenshot_result { if screenshot_response.status().is_success() { println!("Screenshot uploaded successfully"); @@ -727,6 +772,9 @@ impl InstantMultipartUpload { } } + // Send final progress update + // send_progress_update(&app, &video_id, 1.0); + // Copy link to clipboard early let _ = app.clipboard().write_text(pre_created_video.link.clone()); @@ -857,6 +905,8 @@ impl InstantMultipartUpload { } }; + send_progress_update(&app, video_id.into(), file_size, expected_pos); + if !presign_response.status().is_success() { let status = presign_response.status(); let error_body = presign_response diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index e3f4bd5479..d1b47e7c0b 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -2,10 +2,10 @@ import { db } from "@cap/database"; import { sendEmail } from "@cap/database/emails/config"; import { FirstShareableLink } from "@cap/database/emails/first-shareable-link"; import { nanoId } from "@cap/database/helpers"; -import { s3Buckets, uploads, videos } from "@cap/database/schema"; +import { s3Buckets, videoUploads, videos } from "@cap/database/schema"; import { buildEnv, NODE_ENV, serverEnv } from "@cap/env"; import { zValidator } from "@hono/zod-validator"; -import { and, count, eq, gt, gte, lt } from "drizzle-orm"; +import { and, count, eq, gt, gte, lt, lte } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import { dub } from "@/utils/dub"; @@ -108,7 +108,7 @@ app.get( }, }); - await db().insert(uploads).values({ + await db().insert(videoUploads).values({ videoId: idToUse, }); @@ -196,7 +196,7 @@ app.delete( { status: 404 }, ); - await db().delete(uploads).where(eq(uploads.videoId, videoId)); + await db().delete(videoUploads).where(eq(videoUploads.videoId, videoId)); await db() .delete(videos) @@ -226,17 +226,18 @@ app.delete( app.post( "/progress", zValidator( - "query", + "json", z.object({ videoId: z.string(), - progress: z.number(), + uploaded: z.number(), + total: z.number(), // We get this from the client so we can avoid race conditions. // Eg. If this value is older than the value in the DB, we ignore it. - updatedAt: z.date({ coerce: true }), + updatedAt: z.string().pipe(z.coerce.date()), }), ), async (c) => { - const { videoId, progress, updatedAt } = c.req.valid("query"); + const { videoId, uploaded, total, updatedAt } = c.req.valid("json"); const user = c.get("user"); try { @@ -251,25 +252,32 @@ app.post( ); const result = await db() - .update(uploads) + .update(videoUploads) .set({ - progress, + uploaded, + total, updatedAt, }) .where( - and(eq(uploads.videoId, videoId), lt(uploads.updatedAt, updatedAt)), + and( + eq(videoUploads.videoId, videoId), + lte(videoUploads.updatedAt, updatedAt), + ), ); if (result.rowsAffected == 0) { - await db().insert(uploads).values({ + await db().insert(videoUploads).values({ videoId, - progress, + uploaded, + total, updatedAt, }); } - if (progress == 100) { - // TODO: Remove from DB + if (uploaded === total) { + await db() + .delete(videoUploads) + .where(eq(videoUploads.videoId, videoId)); } return c.json(true); diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index e927d24272..ba344b9948 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -1,5 +1,5 @@ import { db } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; +import { s3Buckets, videos, videoUploads } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; import { serverEnv } from "@cap/env"; import { zValidator } from "@hono/zod-validator"; @@ -310,6 +310,12 @@ app.post( } } + const videoId = "videoId" in body ? body.videoId : videoIdFromFileKey; + if (videoId) + await db() + .delete(videoUploads) + .where(eq(videoUploads.videoId, videoId)); + return c.json({ location: result.Location, success: true, diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index d8ce00d284..d6c98caf9e 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -282,19 +282,22 @@ function UploadProgress({ videoId }: { videoId: string }) { const result = useQuery({ queryKey: ["uploadProgress", videoId], queryFn: () => getUploadProgress({ videoId }), - refetchInterval: 3000, + // if a result is returned then an upload is in progress. + refetchInterval: (query) => (query.state.data ? undefined : 3000), }); if (!result.data) return null; const hasUploadFailed = Date.now() - new Date(result.data.updatedAt).getTime() > fiveMinutes; + const progress = (result.data.uploaded / result.data.total) * 100; + return (

{hasUploadFailed ? ( Upload failed ) : ( - {result.data?.progress}% + {progress}% )}

); diff --git a/apps/web/app/s/[videoId]/_components/server.ts b/apps/web/app/s/[videoId]/_components/server.ts index eb8a97ff1b..d1a27b216b 100644 --- a/apps/web/app/s/[videoId]/_components/server.ts +++ b/apps/web/app/s/[videoId]/_components/server.ts @@ -12,15 +12,16 @@ export async function getUploadProgress({ videoId }: { videoId: string }) { const [result] = await db() .select({ - progress: Db.uploads.progress, - startedAt: Db.uploads.startedAt, - updatedAt: Db.uploads.updatedAt, + uploaded: Db.videoUploads.uploaded, + total: Db.videoUploads.total, + startedAt: Db.videoUploads.startedAt, + updatedAt: Db.videoUploads.updatedAt, }) - .from(Db.uploads) - .innerJoin(Db.videos, Dz.eq(Db.uploads.videoId, Db.videos.id)) + .from(Db.videoUploads) + .innerJoin(Db.videos, Dz.eq(Db.videoUploads.videoId, Db.videos.id)) .where( Dz.and( - Dz.eq(Db.uploads.videoId, videoId), + Dz.eq(Db.videoUploads.videoId, videoId), Dz.eq(Db.videos.ownerId, user.id), ), ); diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 12d473dd2c..81662c1f4e 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -630,9 +630,10 @@ export const foldersRelations = relations(folders, ({ one, many }) => ({ videos: many(videos), })); -export const uploads = mysqlTable("uploads", { +export const videoUploads = mysqlTable("video_uploads", { videoId: nanoId("video_id").primaryKey().notNull(), - progress: tinyint("progress").notNull().default(0), + uploaded: int("uploaded").notNull().default(0), + total: int("total").notNull().default(0), startedAt: timestamp("started_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); From b33c9e8ffc0c9a93d0d54161eb49060c133ff154 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Mon, 18 Aug 2025 18:22:31 +0800 Subject: [PATCH 04/36] wip --- apps/desktop/src-tauri/src/upload.rs | 19 ++++++++++--------- apps/web/app/api/desktop/[...route]/video.ts | 15 ++++++++++----- .../s/[videoId]/_components/ShareHeader.tsx | 15 +++++++++++---- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 4cdadca690..da0e7b2717 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -144,7 +144,7 @@ fn send_progress_update(app: &AppHandle, video_id: String, uploaded: u64, total: let progress_percent = (uploaded as f64 / total as f64 * 100.0).round() as u32; println!( - "SENDING PROGRESS UPDATE {:?} {:?} {:?}", + "\n\nSENDING PROGRESS UPDATE {:?} {:?} {:?}", video_id, progress_percent, progress_percent % 5 != 0 && progress_percent != 100 @@ -176,6 +176,8 @@ fn send_progress_update(app: &AppHandle, video_id: String, uploaded: u64, total: error!("Failed to send progress update: {}", resp.status()); } else if let Err(e) = response { error!("Failed to send progress update: {}", e); + } else { + println!("UPDATED OKAY!\n\n"); } }); } @@ -226,11 +228,13 @@ pub async fn upload_video( move |chunk| { if let Ok(chunk) = chunk { bytes_uploaded += chunk.len(); - let progress = bytes_uploaded as f64 / total_size as f64; - - // Emit local progress event for UI - let _ = UploadProgress { progress }.emit(&app); + } + if bytes_uploaded > 0 { + let _ = UploadProgress { + progress: bytes_uploaded as f64 / total_size as f64, + } + .emit(&app); send_progress_update(&app, video_id.clone(), bytes_uploaded as u64, total_size); } } @@ -264,7 +268,6 @@ pub async fn upload_video( if response.status().is_success() { println!("Video uploaded successfully"); - // Send final progress update send_progress_update(&app, video_id.clone(), total_size, total_size); if let Some(Ok(screenshot_response)) = screenshot_result { @@ -772,9 +775,6 @@ impl InstantMultipartUpload { } } - // Send final progress update - // send_progress_update(&app, &video_id, 1.0); - // Copy link to clipboard early let _ = app.clipboard().write_text(pre_created_video.link.clone()); @@ -906,6 +906,7 @@ impl InstantMultipartUpload { }; send_progress_update(&app, video_id.into(), file_size, expected_pos); + tokio::time::sleep(Duration::from_secs(30)).await; // TODO if !presign_response.status().is_success() { let status = presign_response.status(); diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index d1b47e7c0b..f5cab31a8c 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -251,6 +251,8 @@ app.post( { status: 404 }, ); + console.log("\n\n\n\nPROGRESS", videoId, total, uploaded, updatedAt); + const result = await db() .update(videoUploads) .set({ @@ -261,24 +263,27 @@ app.post( .where( and( eq(videoUploads.videoId, videoId), - lte(videoUploads.updatedAt, updatedAt), + // lte(videoUploads.updatedAt, updatedAt), // TODO: bring this back ), ); - if (result.rowsAffected == 0) { - await db().insert(videoUploads).values({ + console.log("ATTEMPTED UPDATE", result); + + if (result.rowsAffected === 0) { + const result2 = await db().insert(videoUploads).values({ videoId, uploaded, total, updatedAt, }); + + console.log("ATTEMPTED INSERT", result2); } - if (uploaded === total) { + if (uploaded === total) await db() .delete(videoUploads) .where(eq(videoUploads.videoId, videoId)); - } return c.json(true); } catch (error) { diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index d6c98caf9e..d08b9ab76d 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -283,21 +283,28 @@ function UploadProgress({ videoId }: { videoId: string }) { queryKey: ["uploadProgress", videoId], queryFn: () => getUploadProgress({ videoId }), // if a result is returned then an upload is in progress. - refetchInterval: (query) => (query.state.data ? undefined : 3000), + refetchInterval: (query) => (!!query.state.data ? 3000 : undefined), }); if (!result.data) return null; const hasUploadFailed = Date.now() - new Date(result.data.updatedAt).getTime() > fiveMinutes; - const progress = (result.data.uploaded / result.data.total) * 100; + console.log(result.data); + + const isPreparing = result.data.total === 0; // `0/0` for progress is `NaN` + const progress = isPreparing + ? 0 + : (result.data.total / result.data.uploaded) * 100; return (

- {hasUploadFailed ? ( + {isPreparing ? ( + Preparing... + ) : hasUploadFailed ? ( Upload failed ) : ( - {progress}% + {progress.toFixed(0)}% )}

); From 715b62ec4273afea3d352767e9f748598c264366 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 26 Aug 2025 12:09:35 +0800 Subject: [PATCH 05/36] wip --- apps/desktop/src-tauri/src/upload.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 205521949e..3a7d71ea49 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -12,6 +12,7 @@ use image::codecs::jpeg::JpegEncoder; use reqwest::StatusCode; use reqwest::header::CONTENT_LENGTH; use serde::{Deserialize, Serialize}; +use serde_json::json; use specta::Type; use std::path::PathBuf; use std::time::Duration; From d9165ee58782c7aedb32449c2b0f6d2541c5bfc4 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 26 Aug 2025 13:09:40 +0800 Subject: [PATCH 06/36] wip --- Cargo.lock | 92 ++++----- apps/desktop/src-tauri/src/upload.rs | 187 +++++++++++++++--- .../s/[videoId]/_components/ShareHeader.tsx | 5 +- .../web/app/s/[videoId]/_components/server.ts | 2 + 4 files changed, 213 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35352aed85..808066c133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,7 +160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41058deaa38c9d9dd933d6d238d825227cffa668e2839b52879f6619c63eee3b" dependencies = [ "futures", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -1383,7 +1383,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -1894,7 +1894,7 @@ dependencies = [ "core-foundation 0.10.1", "core-utils-rs", "core-video-rs", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -1917,7 +1917,7 @@ dependencies = [ "core-graphics 0.24.0", "core-utils-rs", "io-surface", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -3345,7 +3345,7 @@ dependencies = [ "objc2-app-kit", "once_cell", "serde", - "thiserror 2.0.12", + "thiserror 2.0.16", "windows-sys 0.59.0", "x11rb", "xkeysym", @@ -4805,7 +4805,7 @@ dependencies = [ "once_cell", "png", "serde", - "thiserror 2.0.12", + "thiserror 2.0.16", "windows-sys 0.60.2", ] @@ -4830,7 +4830,7 @@ dependencies = [ "rustc-hash 1.1.0", "spirv", "strum 0.26.3", - "thiserror 2.0.12", + "thiserror 2.0.16", "unicode-ident", ] @@ -5690,7 +5690,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -6249,7 +6249,7 @@ dependencies = [ "rustc-hash 2.1.1", "rustls", "socket2 0.5.10", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tracing", "web-time", @@ -6270,7 +6270,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.16", "tinyvec", "tracing", "web-time", @@ -6518,7 +6518,7 @@ dependencies = [ "rustc-hash 2.1.1", "send_wrapper", "slotmap", - "thiserror 2.0.12", + "thiserror 2.0.16", "web-sys", ] @@ -6561,7 +6561,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -6961,7 +6961,7 @@ dependencies = [ "pipewire", "rand 0.8.5", "sysinfo", - "thiserror 2.0.12", + "thiserror 2.0.16", "windows 0.58.0", "windows-capture", ] @@ -8194,7 +8194,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tray-icon", "url", @@ -8247,7 +8247,7 @@ dependencies = [ "sha2", "syn 2.0.104", "tauri-utils", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", "url", "uuid", @@ -8313,7 +8313,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -8329,7 +8329,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", "url", "windows-registry", @@ -8350,7 +8350,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.12", + "thiserror 2.0.16", "url", ] @@ -8371,7 +8371,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.12", + "thiserror 2.0.16", "toml 0.9.5", "url", ] @@ -8388,7 +8388,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -8409,7 +8409,7 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "url", "urlpattern", @@ -8429,7 +8429,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", "url", ] @@ -8464,7 +8464,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.12", + "thiserror 2.0.16", "url", "windows 0.61.3", "zbus", @@ -8485,7 +8485,7 @@ dependencies = [ "sys-locale", "tauri", "tauri-plugin", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -8500,7 +8500,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -8530,7 +8530,7 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", ] @@ -8544,7 +8544,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin-deep-link", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", "windows-sys 0.60.2", "zbus", @@ -8561,7 +8561,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tracing", ] @@ -8590,7 +8590,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", "tokio", "url", @@ -8610,7 +8610,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -8631,7 +8631,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.12", + "thiserror 2.0.16", "url", "webkit2gtk", "webview2-com", @@ -8723,7 +8723,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.12", + "thiserror 2.0.16", "toml 0.9.5", "url", "urlpattern", @@ -8748,7 +8748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml 0.37.5", - "thiserror 2.0.12", + "thiserror 2.0.16", "windows 0.61.3", "windows-version", ] @@ -8797,11 +8797,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.16", ] [[package]] @@ -8817,9 +8817,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -9261,7 +9261,7 @@ dependencies = [ "once_cell", "png", "serde", - "thiserror 2.0.12", + "thiserror 2.0.16", "windows-sys 0.59.0", ] @@ -9941,7 +9941,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ - "thiserror 2.0.12", + "thiserror 2.0.16", "windows 0.61.3", "windows-core 0.61.2", ] @@ -10003,7 +10003,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.16", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", @@ -10077,7 +10077,7 @@ dependencies = [ "raw-window-handle", "renderdoc-sys", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.16", "wasm-bindgen", "web-sys", "wgpu-types", @@ -10095,7 +10095,7 @@ dependencies = [ "bytemuck", "js-sys", "log", - "thiserror 2.0.12", + "thiserror 2.0.16", "web-sys", ] @@ -10270,7 +10270,7 @@ checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" dependencies = [ "parking_lot", "rayon", - "thiserror 2.0.12", + "thiserror 2.0.16", "windows 0.61.3", "windows-future 0.2.1", ] @@ -10895,7 +10895,7 @@ dependencies = [ "os_pipe", "rustix 0.38.44", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -10943,7 +10943,7 @@ dependencies = [ "sha2", "soup3", "tao-macros", - "thiserror 2.0.12", + "thiserror 2.0.16", "url", "webkit2gtk", "webkit2gtk-sys", diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 3a7d71ea49..6c026d31c8 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -14,13 +14,15 @@ use reqwest::header::CONTENT_LENGTH; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; +use std::collections::HashMap; use std::path::PathBuf; -use std::time::Duration; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; use tauri::AppHandle; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_specta::Event; use tokio::io::{AsyncReadExt, AsyncSeekExt}; -use tokio::task; +use tokio::task::{self, JoinHandle}; use tokio::time::sleep; use tracing::{error, info, warn}; @@ -29,6 +31,11 @@ pub struct S3UploadMeta { id: String, } +#[derive(Deserialize, Clone, Debug)] +pub struct CreateErrorResponse { + error: String, +} + // fn deserialize_empty_object_as_string<'de, D>(deserializer: D) -> Result // where // D: Deserializer<'de>, @@ -107,26 +114,131 @@ pub struct UploadedImage { // pub config: S3UploadMeta, // } -// Helper function to send progress updates to the backend API -fn send_progress_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { - let progress_percent = (uploaded as f64 / total as f64 * 100.0).round() as u32; +pub struct UploadProgressUpdater { + video_states: Arc>>, + app: AppHandle, +} - println!( - "\n\nSENDING PROGRESS UPDATE {:?} {:?} {:?}", - video_id, - progress_percent, - progress_percent % 5 != 0 && progress_percent != 100 - ); +struct VideoProgressState { + uploaded: u64, + total: u64, + pending_task: Option>, + last_update_time: Instant, +} + +impl VideoProgressState { + fn new(uploaded: u64, total: u64) -> Self { + Self { + uploaded, + total, + pending_task: None, + last_update_time: Instant::now(), + } + } +} + +// Global registry to manage progress updaters per AppHandle +static PROGRESS_UPDATERS: OnceLock>>> = + OnceLock::new(); + +impl UploadProgressUpdater { + pub fn new(app: AppHandle) -> Self { + Self { + video_states: Arc::new(Mutex::new(HashMap::new())), + app, + } + } + + fn get_or_create_for_app(app: &AppHandle) -> Arc { + let registry = PROGRESS_UPDATERS.get_or_init(|| Mutex::new(HashMap::new())); + let mut updaters = registry.lock().unwrap(); - // Don't spam the API with too many updates - only send every 5% or at completion - if progress_percent % 5 != 0 && progress_percent != 100 { - return; + // Use app handle pointer as unique key + let app_key = format!("{:p}", app as *const _); + + updaters + .entry(app_key) + .or_insert_with(|| Arc::new(UploadProgressUpdater::new(app.clone()))) + .clone() } - let updated_at = chrono::Utc::now().to_rfc3339(); + pub fn update(&self, video_id: String, uploaded: u64, total: u64) { + let states_clone = Arc::clone(&self.video_states); + let app_clone = self.app.clone(); + let video_id_clone = video_id.clone(); + + let should_send_immediately = { + let mut states = states_clone.lock().unwrap(); + + // Get or create state for this video + let state = states + .entry(video_id.clone()) + .or_insert_with(|| VideoProgressState::new(uploaded, total)); + + // Cancel any pending task + if let Some(handle) = state.pending_task.take() { + handle.abort(); + } + + // Update values + state.uploaded = uploaded; + state.total = total; + state.last_update_time = Instant::now(); + + // Send immediately if upload is complete + uploaded >= total + }; + + if should_send_immediately { + // Send completion update immediately + let states_for_cleanup = Arc::clone(&states_clone); + tokio::spawn(async move { + Self::send_api_update(&app_clone, video_id_clone.clone(), uploaded, total).await; + + // Clean up completed video from state + { + let mut states = states_for_cleanup.lock().unwrap(); + states.remove(&video_id_clone); + } + }); + } else { + // Schedule delayed update + let states_for_delayed = Arc::clone(&states_clone); + let handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + + // Get latest values for this video + let (current_uploaded, current_total) = { + let states = states_for_delayed.lock().unwrap(); + if let Some(state) = states.get(&video_id_clone) { + (state.uploaded, state.total) + } else { + return; // Video was removed/completed + } + }; + + Self::send_api_update(&app_clone, video_id_clone, current_uploaded, current_total) + .await; + }); + + // Store the task handle + { + let mut states = states_clone.lock().unwrap(); + if let Some(state) = states.get_mut(&video_id) { + state.pending_task = Some(handle); + } + } + } + } + + async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { + let updated_at = chrono::Utc::now().to_rfc3339(); + + println!( + "📡 Sending batched progress update - Video: {}, Progress: {}/{}", + video_id, uploaded, total + ); - let app = app.clone(); - tokio::spawn(async move { let response = app .authed_api_request("/api/desktop/video/progress", |client, url| { client.post(url).json(&json!({ @@ -138,16 +250,24 @@ fn send_progress_update(app: &AppHandle, video_id: String, uploaded: u64, total: }) .await; - if let Ok(ref resp) = response - && !resp.status().is_success() - { - error!("Failed to send progress update: {}", resp.status()); - } else if let Err(e) = response { - error!("Failed to send progress update: {}", e); - } else { - println!("UPDATED OKAY!\n\n"); + match response { + Ok(resp) if resp.status().is_success() => { + println!("✅ Progress update sent successfully"); + } + Ok(resp) => { + error!("❌ Failed to send progress update: {}", resp.status()); + } + Err(e) => { + error!("❌ Failed to send progress update: {}", e); + } } - }); + } +} + +// Helper function to send progress updates to the backend API +fn send_progress_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { + let updater = UploadProgressUpdater::get_or_create_for_app(app); + updater.update(video_id, uploaded, total); } pub async fn upload_video( @@ -365,6 +485,21 @@ pub async fn create_or_get_video( return Err("Failed to authenticate request; please log in again".into()); } + if response.status() != StatusCode::OK { + if let Ok(error) = response.json::().await { + if error.error == "upgrade_required" { + return Err( + "You must upgrade to Cap Pro to upload recordings over 5 minutes in length" + .into(), + ); + } + + return Err(format!("server error: {}", error.error)); + } + + return Err("Unknown error uploading video".into()); + } + let response_text = response .text() .await diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index d0e95552d0..ca2eeb8594 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -281,7 +281,10 @@ function UploadProgress({ videoId }: { videoId: string }) { queryKey: ["uploadProgress", videoId], queryFn: () => getUploadProgress({ videoId }), // if a result is returned then an upload is in progress. - refetchInterval: (query) => (!!query.state.data ? 3000 : undefined), + // refetchInterval: (query) => (!!query.state.data ? 3000 : undefined), + + // TODO: Fix this + refetchInterval: 3000, }); if (!result.data) return null; diff --git a/apps/web/app/s/[videoId]/_components/server.ts b/apps/web/app/s/[videoId]/_components/server.ts index d1a27b216b..9220382884 100644 --- a/apps/web/app/s/[videoId]/_components/server.ts +++ b/apps/web/app/s/[videoId]/_components/server.ts @@ -6,6 +6,8 @@ import * as Db from "@cap/database/schema"; import * as Dz from "drizzle-orm"; export async function getUploadProgress({ videoId }: { videoId: string }) { + console.log("getUploadProgress"); + const user = await getCurrentUser(); if (!user || !user.activeOrganizationId) throw new Error("Unauthorized or no active organization"); From 1481f3c405c9a86c28ab15819b884b1f449b9ae8 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 28 Aug 2025 21:07:00 +0800 Subject: [PATCH 07/36] cleanup --- apps/desktop/src-tauri/src/upload.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 6c026d31c8..f161733dbf 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -24,7 +24,7 @@ use tauri_specta::Event; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio::task::{self, JoinHandle}; use tokio::time::sleep; -use tracing::{error, info, warn}; +use tracing::{error, info, trace, warn}; #[derive(Deserialize, Serialize, Clone, Type, Debug)] pub struct S3UploadMeta { @@ -234,10 +234,10 @@ impl UploadProgressUpdater { async fn send_api_update(app: &AppHandle, video_id: String, uploaded: u64, total: u64) { let updated_at = chrono::Utc::now().to_rfc3339(); - println!( - "📡 Sending batched progress update - Video: {}, Progress: {}/{}", - video_id, uploaded, total - ); + // println!( + // "📡 Sending batched progress update - Video: {}, Progress: {}/{}", + // video_id, uploaded, total + // ); let response = app .authed_api_request("/api/desktop/video/progress", |client, url| { @@ -252,14 +252,10 @@ impl UploadProgressUpdater { match response { Ok(resp) if resp.status().is_success() => { - println!("✅ Progress update sent successfully"); - } - Ok(resp) => { - error!("❌ Failed to send progress update: {}", resp.status()); - } - Err(e) => { - error!("❌ Failed to send progress update: {}", e); + trace!("Progress update sent successfully"); } + Ok(resp) => error!("Failed to send progress update: {}", resp.status()), + Err(err) => error!("Failed to send progress update: {err}"), } } } From 5bd0d3cba4286cdd3d9e59b25abd487f50ce7297 Mon Sep 17 00:00:00 2001 From: ameer2468 <33054370+ameer2468@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:35:25 +0300 Subject: [PATCH 08/36] progress circle in video while uploading placeholder --- .../[videoId]/_components/CapVideoPlayer.tsx | 231 ++++++++++-------- .../[videoId]/_components/HLSVideoPlayer.tsx | 197 ++++++++------- .../[videoId]/_components/ProgressCircle.tsx | 65 +++++ 3 files changed, 298 insertions(+), 195 deletions(-) create mode 100644 apps/web/app/s/[videoId]/_components/ProgressCircle.tsx diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index e40b0f6c43..f34637a15a 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -6,6 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; +import ProgressCircle from "./ProgressCircle"; import { MediaPlayer, MediaPlayerCaptions, @@ -379,123 +380,141 @@ export function CapVideoPlayer({ return `https://placeholder.pics/svg/224x128/dc2626/ffffff/Error`; }, []); + const isUploading = true; + return ( - <> - setControlsVisible(true)} - onMouseLeave={() => setControlsVisible(false)} - onTouchStart={() => setControlsVisible(true)} - onTouchEnd={() => setControlsVisible(false)} + setControlsVisible(true)} + onMouseLeave={() => setControlsVisible(false)} + onTouchStart={() => setControlsVisible(true)} + onTouchEnd={() => setControlsVisible(false)} + className={clsx( + mediaPlayerClassName, + "[&::-webkit-media-text-track-display]:!hidden", + )} + autoHide + > +
-
+ + {retryCount.current > 0 && ( +

+ Preparing video... ({retryCount.current}/{maxRetries}) +

)} - > -
- - {retryCount.current > 0 && ( -

- Preparing video... ({retryCount.current}/{maxRetries}) -

- )} -
- {urlResolved && ( - { - setVideoLoaded(true); - }} - onPlay={() => { - setShowPlayButton(false); - setHasPlayedOnce(true); - }} - crossOrigin={useCrossOrigin ? "anonymous" : undefined} - playsInline - autoPlay={autoplay} +
+ {urlResolved && ( + { + setVideoLoaded(true); + }} + onPlay={() => { + setShowPlayButton(false); + setHasPlayedOnce(true); + }} + crossOrigin={useCrossOrigin ? "anonymous" : undefined} + playsInline + autoPlay={autoplay} + > + + + + )} + + {isUploading && ( + + {/* Progress Circle */} + + + )} + {showPlayButton && videoLoaded && !hasPlayedOnce && !isUploading && ( + videoRef.current?.play()} + className="flex absolute inset-0 z-10 justify-center items-center m-auto bg-blue-500 rounded-full transition-colors transform cursor-pointer hover:bg-blue-600 size-12 xs:size-20 md:size-32" > - - - + )} - - {showPlayButton && videoLoaded && !hasPlayedOnce && ( - videoRef.current?.play()} - className="flex absolute inset-0 z-10 justify-center items-center m-auto bg-blue-500 rounded-full transition-colors transform cursor-pointer hover:bg-blue-600 size-12 xs:size-20 md:size-32" - > - - + + {currentCue && toggleCaptions && ( +
- {currentCue && toggleCaptions && ( -
- {currentCue} + > + {currentCue} +
+ )} + + {!isRetrying && !isRetryingRef.current && !isUploading && ( + + )} + + + + +
+
+ + + + +
- )} - - {!isRetrying && !isRetryingRef.current && } - - - - -
-
- - - - - -
-
- - - - -
+
+ + + +
- - - +
+
+ ); } diff --git a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx index 9d5515432c..e975879337 100644 --- a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx @@ -6,7 +6,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; import { AnimatePresence, motion } from "framer-motion"; import Hls from "hls.js"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import ProgressCircle from "./ProgressCircle"; import { MediaPlayer, MediaPlayerCaptions, @@ -244,104 +245,122 @@ export function HLSVideoPlayer({ }; }, [captionsSrc]); + const isUploading = true; + return ( - <> - setControlsVisible(true)} - onMouseLeave={() => setControlsVisible(false)} - onTouchStart={() => setControlsVisible(true)} - onTouchEnd={() => setControlsVisible(false)} + setControlsVisible(true)} + onMouseLeave={() => setControlsVisible(false)} + onTouchStart={() => setControlsVisible(true)} + onTouchEnd={() => setControlsVisible(false)} + className={clsx( + mediaPlayerClassName, + "[&::-webkit-media-text-track-display]:!hidden", + )} + autoHide + > +
+
+ +
+
+ + {isUploading && ( + + {/* Progress Circle */} + + + )} + {showPlayButton && videoLoaded && !hasPlayedOnce && !isUploading && ( + videoRef.current?.play()} + className="flex absolute inset-0 z-10 justify-center items-center m-auto bg-blue-500 rounded-full transition-colors transform cursor-pointer hover:bg-blue-600 size-12 xs:size-20 md:size-32" + > + + )} - autoHide + + { + setShowPlayButton(false); + setHasPlayedOnce(true); + }} + playsInline + autoPlay={autoplay} > + + + + {currentCue && toggleCaptions && (
- + {currentCue}
- - {showPlayButton && videoLoaded && !hasPlayedOnce && ( - videoRef.current?.play()} - className="flex absolute inset-0 z-10 justify-center items-center m-auto bg-blue-500 rounded-full transition-colors transform cursor-pointer hover:bg-blue-600 size-12 xs:size-20 md:size-32" - > - - - )} - - { - setShowPlayButton(false); - setHasPlayedOnce(true); - }} - playsInline - autoPlay={autoplay} - > - - - - {currentCue && toggleCaptions && ( -
- {currentCue} + )} + + + + + + +
+
+ + + + +
- )} - - - - - - -
-
- - - - - -
-
- - - - -
+
+ + + +
- - - +
+
+ ); } diff --git a/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx new file mode 100644 index 0000000000..0aa3df10c5 --- /dev/null +++ b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const ProgressCircle = ({ isUploading }: { isUploading: boolean }) => { + const [uploadProgress, setUploadProgress] = useState(0); + + // Animate progress from 0 to 100 + useEffect(() => { + if (isUploading) { + const interval = setInterval(() => { + setUploadProgress((prev) => { + if (prev >= 100) { + clearInterval(interval); + return 100; + } + return prev + 1; + }); + }, 50); // Update every 50ms for smooth animation + + return () => clearInterval(interval); + } + }, [isUploading]); + + return ( +
+ + Progress Circle + {/* Background circle */} + + {/* Progress circle */} + + + {/* Progress text */} +
+ + {Math.round(uploadProgress)}% + + + Uploading Video... + +
+
+ ); +}; + +export default ProgressCircle; From a36dd5bd437234ba5f802db0155000b3f5f63639 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Thu, 11 Sep 2025 14:28:57 +0800 Subject: [PATCH 09/36] we love effect --- .../[videoId]/_components/EmbedVideo.tsx | 2 + .../[videoId]/_components/CapVideoPlayer.tsx | 11 ++- .../[videoId]/_components/HLSVideoPlayer.tsx | 11 ++- .../[videoId]/_components/ProgressCircle.tsx | 74 ++++++++++++++----- .../s/[videoId]/_components/ShareHeader.tsx | 45 +---------- .../s/[videoId]/_components/ShareVideo.tsx | 2 + .../web/app/s/[videoId]/_components/server.ts | 32 -------- packages/web-backend/src/Videos/VideosRpcs.ts | 14 +++- packages/web-backend/src/Videos/index.ts | 34 ++++++++- packages/web-domain/src/Errors.ts | 2 +- packages/web-domain/src/Video.ts | 19 +++++ 11 files changed, 138 insertions(+), 108 deletions(-) delete mode 100644 apps/web/app/s/[videoId]/_components/server.ts diff --git a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx index a94617ae88..8e3cd4f6eb 100644 --- a/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx +++ b/apps/web/app/embed/[videoId]/_components/EmbedVideo.tsx @@ -198,6 +198,7 @@ export const EmbedVideo = forwardRef<
{data.source.type === "desktopMP4" ? ( ) : ( ; @@ -39,6 +41,7 @@ interface Props { export function CapVideoPlayer({ videoSrc, + videoId, chaptersSrc, captionsSrc, videoRef, @@ -380,7 +383,8 @@ export function CapVideoPlayer({ return `https://placeholder.pics/svg/224x128/dc2626/ffffff/Error`; }, []); - const isUploading = true; + const uploadProgress = useUploadProgress(videoId); + const isUploading = uploadProgress?.status === "uploading"; return ( - {/* Progress Circle */} - + )} {showPlayButton && videoLoaded && !hasPlayedOnce && !isUploading && ( diff --git a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx index e975879337..b3c96cdbda 100644 --- a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx @@ -7,7 +7,7 @@ import clsx from "clsx"; import { AnimatePresence, motion } from "framer-motion"; import Hls from "hls.js"; import { useEffect, useRef, useState } from "react"; -import ProgressCircle from "./ProgressCircle"; +import ProgressCircle, { useUploadProgress } from "./ProgressCircle"; import { MediaPlayer, MediaPlayerCaptions, @@ -27,8 +27,10 @@ import { MediaPlayerVolume, MediaPlayerVolumeIndicator, } from "./video/media-player"; +import type { Video } from "@cap/web-domain"; interface Props { + videoId: Video.VideoId; videoSrc: string; chaptersSrc: string; captionsSrc: string; @@ -38,6 +40,7 @@ interface Props { } export function HLSVideoPlayer({ + videoId, videoSrc, chaptersSrc, captionsSrc, @@ -245,7 +248,8 @@ export function HLSVideoPlayer({ }; }, [captionsSrc]); - const isUploading = true; + const uploadProgress = useUploadProgress(videoId); + const isUploading = uploadProgress?.status === "uploading"; return ( - {/* Progress Circle */} - + )} {showPlayButton && videoLoaded && !hasPlayedOnce && !isUploading && ( diff --git a/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx index 0aa3df10c5..7d37ea8a01 100644 --- a/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx +++ b/apps/web/app/s/[videoId]/_components/ProgressCircle.tsx @@ -1,27 +1,61 @@ "use client"; -import { useEffect, useState } from "react"; +import { Option } from "effect"; +import { useEffectQuery } from "@/lib/EffectRuntime"; +import { withRpc } from "@/lib/Rpcs"; +import type { Video } from "@cap/web-domain"; -const ProgressCircle = ({ isUploading }: { isUploading: boolean }) => { - const [uploadProgress, setUploadProgress] = useState(0); +type UploadProgress = + | { + status: "preparing"; + } + | { + status: "uploading"; + progress: number; + } + | { + status: "failed"; + }; - // Animate progress from 0 to 100 - useEffect(() => { - if (isUploading) { - const interval = setInterval(() => { - setUploadProgress((prev) => { - if (prev >= 100) { - clearInterval(interval); - return 100; - } - return prev + 1; - }); - }, 50); // Update every 50ms for smooth animation +const fiveMinutes = 5 * 60 * 1000; + +export function useUploadProgress(videoId: Video.VideoId) { + const query = useEffectQuery({ + queryKey: ["getUploadProgress", videoId], + queryFn: () => withRpc((rpc) => rpc.GetUploadProgress(videoId)), + refetchInterval: (query) => (!!query.state.data ? 1000 : false), + }); + + const result = Option.getOrUndefined(query.data ?? Option.none()); + if (!result) return null; + + const hasUploadFailed = + Date.now() - new Date(result.updatedAt).getTime() > fiveMinutes; + + console.log( + Date.now() - new Date(result.updatedAt).getTime(), + hasUploadFailed, + ); + + const isPreparing = result.total === 0; // `0/0` for progress is `NaN` - return () => clearInterval(interval); - } - }, [isUploading]); + return ( + isPreparing + ? { + status: "preparing", + } + : hasUploadFailed + ? { + status: "failed", + } + : { + status: "uploading", + progress: (result.uploaded / result.total) * 100, + } + ) satisfies UploadProgress; +} +const ProgressCircle = ({ progress }: { progress: number }) => { return (
@@ -45,14 +79,14 @@ const ProgressCircle = ({ isUploading }: { isUploading: boolean }) => { strokeWidth="5" strokeLinecap="round" strokeDasharray={`${2 * Math.PI * 45}`} - strokeDashoffset={`${2 * Math.PI * 45 * (1 - uploadProgress / 100)}`} + strokeDashoffset={`${2 * Math.PI * 45 * (1 - progress / 100)}`} className="transition-all duration-300 ease-out" /> {/* Progress text */}
- {Math.round(uploadProgress)}% + {Math.round(progress)}% Uploading Video... diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 4f97cb5be4..8c73a53e32 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -11,7 +11,7 @@ import clsx from "clsx"; import { Check, Copy, Globe2 } from "lucide-react"; import moment from "moment"; import { useRouter } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { editTitle } from "@/actions/videos/edit-title"; import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; @@ -19,9 +19,6 @@ import { SharingDialog } from "@/app/(org)/dashboard/caps/components/SharingDial import type { Spaces } from "@/app/(org)/dashboard/dashboard-data"; import { UpgradeModal } from "@/components/UpgradeModal"; import { usePublicEnv } from "@/utils/public-env"; -import { db } from "@cap/database"; -import { useQuery } from "@tanstack/react-query"; -import { getUploadProgress } from "./server"; export const ShareHeader = ({ data, @@ -230,10 +227,6 @@ export const ShareHeader = ({

{moment(data.createdAt).fromNow()}

- - - -
{user !== null && ( @@ -300,39 +293,3 @@ export const ShareHeader = ({ ); }; - -const fiveMinutes = 5 * 60 * 1000; -function UploadProgress({ videoId }: { videoId: string }) { - const result = useQuery({ - queryKey: ["uploadProgress", videoId], - queryFn: () => getUploadProgress({ videoId }), - // if a result is returned then an upload is in progress. - // refetchInterval: (query) => (!!query.state.data ? 3000 : undefined), - - // TODO: Fix this - refetchInterval: 3000, - }); - if (!result.data) return null; - - const hasUploadFailed = - Date.now() - new Date(result.data.updatedAt).getTime() > fiveMinutes; - - console.log(result.data); - - const isPreparing = result.data.total === 0; // `0/0` for progress is `NaN` - const progress = isPreparing - ? 0 - : (result.data.total / result.data.uploaded) * 100; - - return ( -

- {isPreparing ? ( - Preparing... - ) : hasUploadFailed ? ( - Upload failed - ) : ( - {progress.toFixed(0)}% - )} -

- ); -} diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index 855604ce0b..93edd9486f 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -150,6 +150,7 @@ export const ShareVideo = forwardRef<
{data.source.type === "desktopMP4" ? ( ) : ( new InternalError({ type: "database" }), S3Error: () => new InternalError({ type: "s3" }), - UnknownException: () => new InternalError({ type: "s3" }), + UnknownException: () => new InternalError({ type: "unknown" }), }), ), VideoDuplicate: (videoId) => @@ -21,7 +22,16 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer( Effect.catchTags({ DatabaseError: () => new InternalError({ type: "database" }), S3Error: () => new InternalError({ type: "s3" }), - UnknownException: () => new InternalError({ type: "s3" }), + UnknownException: () => new InternalError({ type: "unknown" }), + }), + ), + GetUploadProgress: (videoId) => + videos.getUploadProgress(videoId).pipe( + provideOptionalAuth, + (v) => v, + Effect.catchTags({ + DatabaseError: () => new InternalError({ type: "database" }), + UnknownException: () => new InternalError({ type: "unknown" }), }), ), }; diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts index 81d8bc0314..94178a525a 100644 --- a/packages/web-backend/src/Videos/index.ts +++ b/packages/web-backend/src/Videos/index.ts @@ -1,9 +1,12 @@ import { CurrentUser, Policy, Video } from "@cap/web-domain"; -import { Array, Effect, Option } from "effect"; +import { Array, Effect, Option, pipe } from "effect"; import { S3Buckets } from "../S3Buckets"; import { S3BucketAccess } from "../S3Buckets/S3BucketAccess"; import { VideosPolicy } from "./VideosPolicy"; import { VideosRepo } from "./VideosRepo"; +import { Database } from "../Database"; +import * as Db from "@cap/database/schema"; +import * as Dz from "drizzle-orm"; export class Videos extends Effect.Service()("Videos", { effect: Effect.gen(function* () { @@ -103,6 +106,35 @@ export class Videos extends Effect.Service()("Videos", { ); }).pipe(Effect.provide(S3ProviderLayer)); }), + + /* + * Gets the progress of a video upload. + */ + getUploadProgress: Effect.fn("Videos.getUploadProgress")(function* ( + videoId: Video.VideoId, + ) { + const db = yield* Database; + + const [result] = yield* db + .execute((db) => + db + .select({ + uploaded: Db.videoUploads.uploaded, + total: Db.videoUploads.total, + startedAt: Db.videoUploads.startedAt, + updatedAt: Db.videoUploads.updatedAt, + }) + .from(Db.videoUploads) + .where(Dz.eq(Db.videoUploads.videoId, videoId)), + ) + .pipe(Policy.withPublicPolicy(policy.canView(videoId))); + + return pipe( + result, + Option.fromNullable, + Option.map((r) => new Video.UploadProgress(r)), + ); + }), }; }), dependencies: [VideosPolicy.Default, VideosRepo.Default, S3Buckets.Default], diff --git a/packages/web-domain/src/Errors.ts b/packages/web-domain/src/Errors.ts index 07a8ed26d4..597d8c3d90 100644 --- a/packages/web-domain/src/Errors.ts +++ b/packages/web-domain/src/Errors.ts @@ -2,5 +2,5 @@ import { Schema } from "effect"; export class InternalError extends Schema.TaggedError()( "InternalError", - { type: Schema.Literal("database", "s3") }, + { type: Schema.Literal("database", "s3", "unknown") }, ) {} diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 8df0deb362..5d21d2f679 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -34,6 +34,15 @@ export class Video extends Schema.Class