diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index d60dbd949..96a7f2bb1 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tauri::AppHandle; -use tracing::instrument; +use tracing::{instrument, trace}; use crate::web_api::{AuthedApiError, ManagerExt}; @@ -46,7 +46,7 @@ pub async fn upload_multipart_initiate( .map(|data| data.upload_id) } -#[instrument] +#[instrument(skip(upload_id))] pub async fn upload_multipart_presign_part( app: &AppHandle, video_id: &str, @@ -110,7 +110,7 @@ pub struct S3VideoMeta { pub fps: Option, } -#[instrument] +#[instrument(skip_all)] pub async fn upload_multipart_complete( app: &AppHandle, video_id: &str, @@ -133,6 +133,8 @@ pub async fn upload_multipart_complete( location: Option, } + trace!("Completing multipart upload"); + let resp = app .authed_api_request("/api/upload/multipart/complete", |c, url| { c.post(url) diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index f741806eb..06e37f082 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -616,7 +616,7 @@ fn retryable_client(host: String) -> reqwest::ClientBuilder { /// Takes an incoming stream of bytes and individually uploads them to S3. /// /// Note: It's on the caller to ensure the chunks are sized correctly within S3 limits. -#[instrument(skip(app, stream))] +#[instrument(skip(app, stream, upload_id))] fn multipart_uploader( app: AppHandle, video_id: String, diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index e0c44bf29..9a5bb990f 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -4,7 +4,7 @@ import { createEventListenerMap, } from "@solid-primitives/event-listener"; import { useSearchParams } from "@solidjs/router"; -import { createQuery } from "@tanstack/solid-query"; +import { createQuery, useMutation } from "@tanstack/solid-query"; import { emit } from "@tauri-apps/api/event"; import { CheckMenuItem, Menu, Submenu } from "@tauri-apps/api/menu"; import * as dialog from "@tauri-apps/plugin-dialog"; @@ -795,6 +795,18 @@ function RecordingControls(props: { return await Menu.new({ items: [await countdownMenu()] }); }; + const startRecording = useMutation(() => ({ + mutationFn: () => + handleRecordingResult( + commands.startRecording({ + capture_target: props.target, + mode: rawOptions.mode, + capture_system_audio: rawOptions.captureSystemAudio, + }), + setOptions, + ), + })); + return ( <>
@@ -805,43 +817,48 @@ function RecordingControls(props: {
{ if (rawOptions.mode === "instant" && !auth.data) { emit("start-sign-in"); return; } + if (startRecording.isPending) return; - handleRecordingResult( - commands.startRecording({ - capture_target: props.target, - mode: rawOptions.mode, - capture_system_audio: rawOptions.captureSystemAudio, - }), - setOptions, - ); + startRecording.mutate(); }} > -
+
{rawOptions.mode === "studio" ? ( ) : ( )}
- + {rawOptions.mode === "instant" && !auth.data ? "Sign In To Use" : "Start Recording"} - + {`${capitalize(rawOptions.mode)} Mode`}
{ e.stopPropagation(); menuModes().then((menu) => menu.popup()); diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index 94b8c2251..a6fa4d6e6 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -549,7 +549,9 @@ export const CapCard = ({ }} href={`/s/${cap.id}`} > - {imageStatus !== "success" && uploadProgress ? ( + {imageStatus !== "success" && + uploadProgress && + uploadProgress?.status !== "fetching" ? (
diff --git a/apps/web/app/api/desktop/[...route]/video.ts b/apps/web/app/api/desktop/[...route]/video.ts index 90837ef8d..b4f48db48 100644 --- a/apps/web/app/api/desktop/[...route]/video.ts +++ b/apps/web/app/api/desktop/[...route]/video.ts @@ -209,12 +209,7 @@ app.get( .from(videos) .where(eq(videos.ownerId, user.id)); - if ( - videoCount && - videoCount[0] && - videoCount[0].count === 1 && - user.email - ) { + if (videoCount?.[0] && videoCount[0].count === 1 && user.email) { console.log( "[SendFirstShareableLinkEmail] Sending first shareable link email with 5-minute delay", ); @@ -370,10 +365,10 @@ app.post( updatedAt, }); - if (uploaded === total) - await db() - .delete(videoUploads) - .where(eq(videoUploads.videoId, videoId)); + // 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/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 84c70eb08..ccff91ed4 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -4,6 +4,7 @@ import { LogoSpinner } from "@cap/ui"; import type { Video } from "@cap/web-domain"; import { faPlay } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { skipToken, useQuery } from "@tanstack/react-query"; import clsx from "clsx"; import { AnimatePresence, motion } from "framer-motion"; import { AlertTriangleIcon } from "lucide-react"; @@ -77,9 +78,6 @@ export function CapVideoPlayer({ const [videoLoaded, setVideoLoaded] = useState(false); const [hasPlayedOnce, setHasPlayedOnce] = useState(false); const [isMobile, setIsMobile] = useState(false); - const [resolvedVideoSrc, setResolvedVideoSrc] = useState(videoSrc); - const [useCrossOrigin, setUseCrossOrigin] = useState(enableCrossOrigin); - const [urlResolved, setUrlResolved] = useState(false); const retryCount = useRef(0); const retryTimeout = useRef(null); const startTime = useRef(Date.now()); @@ -100,50 +98,71 @@ export function CapVideoPlayer({ return () => window.removeEventListener("resize", checkMobile); }, []); - const fetchNewUrl = useCallback(async () => { - try { - const timestamp = new Date().getTime(); - const urlWithTimestamp = videoSrc.includes("?") - ? `${videoSrc}&_t=${timestamp}` - : `${videoSrc}?_t=${timestamp}`; - - const response = await fetch(urlWithTimestamp, { - method: "GET", - headers: { range: "bytes=0-0" }, - }); - const finalUrl = response.redirected ? response.url : urlWithTimestamp; - - // Check if the resolved URL is from a CORS-incompatible service - const isCloudflareR2 = finalUrl.includes(".r2.cloudflarestorage.com"); - const isS3 = - finalUrl.includes(".s3.") || finalUrl.includes("amazonaws.com"); - const isCorsIncompatible = isCloudflareR2 || isS3; - - // Set CORS based on URL compatibility BEFORE video element is created - if (isCorsIncompatible) { - console.log( - "CapVideoPlayer: Detected CORS-incompatible URL, disabling crossOrigin:", - finalUrl, - ); - setUseCrossOrigin(false); - } else { - setUseCrossOrigin(enableCrossOrigin); - } + const uploadProgressRaw = useUploadProgress( + videoId, + hasActiveUpload || false, + ); + // if the video comes back from S3, just ignore the upload progress. + const uploadProgress = videoLoaded ? null : uploadProgressRaw; + const isUploading = uploadProgress?.status === "uploading"; + const isUploadProgressPending = uploadProgress?.status === "fetching"; + + const resolvedSrc = useQuery<{ url: string; supportsCrossOrigin: boolean }>({ + queryKey: ["resolvedSrc", videoSrc], + queryFn: + isUploadProgressPending || isUploading + ? skipToken + : async () => { + try { + const timestamp = Date.now(); + const urlWithTimestamp = videoSrc.includes("?") + ? `${videoSrc}&_t=${timestamp}` + : `${videoSrc}?_t=${timestamp}`; + + const response = await fetch(urlWithTimestamp, { + method: "GET", + headers: { range: "bytes=0-0" }, + }); + const finalUrl = response.redirected + ? response.url + : urlWithTimestamp; + + // Check if the resolved URL is from a CORS-incompatible service + const isCloudflareR2 = finalUrl.includes( + ".r2.cloudflarestorage.com", + ); + const isS3 = + finalUrl.includes(".s3.") || finalUrl.includes("amazonaws.com"); + const isCorsIncompatible = isCloudflareR2 || isS3; + + let supportsCrossOrigin = enableCrossOrigin; + + // Set CORS based on URL compatibility BEFORE video element is created + if (isCorsIncompatible) { + console.log( + "CapVideoPlayer: Detected CORS-incompatible URL, disabling crossOrigin:", + finalUrl, + ); + supportsCrossOrigin = false; + } - setResolvedVideoSrc(finalUrl); - setUrlResolved(true); - return finalUrl; - } catch (error) { - console.error("CapVideoPlayer: Error fetching new video URL:", error); - const timestamp = new Date().getTime(); - const fallbackUrl = videoSrc.includes("?") - ? `${videoSrc}&_t=${timestamp}` - : `${videoSrc}?_t=${timestamp}`; - setResolvedVideoSrc(fallbackUrl); - setUrlResolved(true); - return fallbackUrl; - } - }, [videoSrc, enableCrossOrigin]); + return { url: finalUrl, supportsCrossOrigin }; + } catch (error) { + console.error( + "CapVideoPlayer: Error fetching new video URL:", + error, + ); + const timestamp = Date.now(); + const fallbackUrl = videoSrc.includes("?") + ? `${videoSrc}&_t=${timestamp}` + : `${videoSrc}?_t=${timestamp}`; + return { + url: fallbackUrl, + supportsCrossOrigin: enableCrossOrigin, + }; + } + }, + }); const reloadVideo = useCallback(async () => { const video = videoRef.current; @@ -172,7 +191,7 @@ export function CapVideoPlayer({ } retryCount.current += 1; - }, [fetchNewUrl, maxRetries]); + }, [maxRetries]); const setupRetry = useCallback(() => { if (retryTimeout.current) { @@ -216,26 +235,19 @@ export function CapVideoPlayer({ // Reset state when video source changes useEffect(() => { - setResolvedVideoSrc(videoSrc); + resolvedSrc.refetch(); setVideoLoaded(false); setHasError(false); isRetryingRef.current = false; setIsRetrying(false); retryCount.current = 0; startTime.current = Date.now(); - setUrlResolved(false); - setUseCrossOrigin(enableCrossOrigin); if (retryTimeout.current) { clearTimeout(retryTimeout.current); retryTimeout.current = null; } - }, [videoSrc, enableCrossOrigin]); - - // Resolve video URL on mount and when videoSrc changes - useEffect(() => { - fetchNewUrl(); - }, [fetchNewUrl]); + }, [resolvedSrc.refetch, videoSrc, enableCrossOrigin]); // Track video duration for comment markers useEffect(() => { @@ -251,7 +263,7 @@ export function CapVideoPlayer({ return () => { video.removeEventListener("loadedmetadata", handleLoadedMetadata); }; - }, [urlResolved]); + }, [resolvedSrc.isLoading]); // Track when all data is ready for comment markers const [markersReady, setMarkersReady] = useState(false); @@ -275,7 +287,7 @@ export function CapVideoPlayer({ useEffect(() => { const video = videoRef.current; - if (!video || !urlResolved) return; + if (!video || resolvedSrc.isLoading) return; const handleLoadedData = () => { setVideoLoaded(true); @@ -445,7 +457,7 @@ export function CapVideoPlayer({ captionTrack.removeEventListener("cuechange", handleCueChange); } }; - }, [hasPlayedOnce, videoSrc, urlResolved]); + }, [hasPlayedOnce, videoSrc, resolvedSrc.isLoading]); const generateVideoFrameThumbnail = useCallback((time: number): string => { const video = videoRef.current; @@ -470,13 +482,6 @@ export function CapVideoPlayer({ return `https://placeholder.pics/svg/224x128/dc2626/ffffff/Error`; }, []); - const uploadProgressRaw = useUploadProgress( - videoId, - hasActiveUpload || false, - ); - // if the video comes back from S3, just ignore the upload progress. - const uploadProgress = videoLoaded ? null : uploadProgressRaw; - const isUploading = uploadProgress?.status === "uploading"; const isUploadFailed = uploadProgress?.status === "failed"; const prevUploadProgress = useRef(uploadProgress); @@ -529,9 +534,9 @@ export function CapVideoPlayer({ )}
- {urlResolved && ( + {resolvedSrc.isSuccess && ( { setVideoLoaded(true); @@ -540,7 +545,9 @@ export function CapVideoPlayer({ setShowPlayButton(false); setHasPlayedOnce(true); }} - crossOrigin={useCrossOrigin ? "anonymous" : undefined} + crossOrigin={ + resolvedSrc.data.supportsCrossOrigin ? "anonymous" : undefined + } playsInline autoPlay={autoplay} > @@ -654,7 +661,7 @@ export function CapVideoPlayer({ @@ -41,25 +44,27 @@ export function useUploadProgress(videoId: Video.VideoId, enabled: boolean) { else return SECOND; }, }); - if (!enabled || !query.data) return null; + + if (!enabled) return null; + if (query.isPending) return { status: "fetching" }; + if (!query.data) return null; + const lastUpdated = new Date(query.data.updatedAt); - return ( - Date.now() - lastUpdated.getTime() > 5 * MINUTE - ? { - status: "failed", - lastUpdated, - } - : { - status: "uploading", - lastUpdated, - progress: - // `0/0` for progress is `NaN` - query.data.total === 0 - ? 0 - : (query.data.uploaded / query.data.total) * 100, - } - ) satisfies UploadProgress; + return Date.now() - lastUpdated.getTime() > 5 * MINUTE + ? { + status: "failed", + lastUpdated, + } + : { + status: "uploading", + lastUpdated, + progress: + // `0/0` for progress is `NaN` + query.data.total === 0 + ? 0 + : (query.data.uploaded / query.data.total) * 100, + }; } const ProgressCircle = ({ diff --git a/apps/web/instrumentation.node.ts b/apps/web/instrumentation.node.ts index f23d1e707..bab20c572 100644 --- a/apps/web/instrumentation.node.ts +++ b/apps/web/instrumentation.node.ts @@ -14,6 +14,8 @@ import { migrate } from "drizzle-orm/mysql2/migrator"; import path from "path"; export async function register() { + if (process.env.NEXT_PUBLIC_IS_CAP) return; + console.log("Waiting 5 seconds to run migrations"); // Function to trigger migrations with retry logic const triggerMigrations = async (retryCount = 0, maxRetries = 3) => {