diff --git a/.env.example b/.env.example index 6ed4f524..55ea2603 100644 --- a/.env.example +++ b/.env.example @@ -74,4 +74,4 @@ CAP_DESKTOP_SENTRY_URL=https://efd3156d9c0a8a49bee3ee675bec80d8@o450685977152716 # Google OAuth GOOGLE_CLIENT_ID="your-google-client-id" -GOOGLE_CLIENT_SECRET="your-google-client-secret" +GOOGLE_CLIENT_SECRET="your-google-client-secret" \ No newline at end of file diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 99219cbe..e0a8980c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1536,12 +1536,11 @@ async fn delete_auth_open_signin(app: AppHandle) -> Result<(), String> { if let Some(window) = CapWindowId::Camera.get(&app) { window.close().ok(); } - if let Some(window) = CapWindowId::Main.get(&app) { window.close().ok(); } - while CapWindowId::Main.get(&app).is_some() { + while CapWindowId::Main.get(&app).is_none() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 1ecadcd7..48f887b9 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -414,12 +414,12 @@ fn position_traffic_lights_impl( let c_win = window.clone(); window .run_on_main_thread(move || { + let ns_window = match c_win.ns_window() { + Ok(handle) => handle, + Err(_) => return, + }; position_window_controls( - UnsafeWindowHandle( - c_win - .ns_window() - .expect("Failed to get native window handle"), - ), + UnsafeWindowHandle(ns_window), &controls_inset.unwrap_or(DEFAULT_TRAFFIC_LIGHTS_INSET), ); }) diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index 07013faf..bfe9bd0e 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -78,6 +78,8 @@ export default function () { }, })); + const [isUpgraded] = createResource(() => commands.checkUpgradedAndUpdate()); + createAsync(() => getAuth()); createUpdateCheck(); @@ -118,7 +120,23 @@ export default function () { return (
- +
+ + { + if (!isUpgraded()) { + await commands.showWindow("Upgrade"); + } + }} + class={`text-[0.625rem] ${ + isUpgraded() + ? "bg-blue-400 text-gray-50" + : "bg-gray-200 text-gray-400 cursor-pointer hover:bg-gray-300" + } rounded-lg px-1.5 py-0.5`} + > + {isUpgraded() ? "Pro" : "Free"} + +
diff --git a/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx b/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx index 56c46e93..f70f5f84 100644 --- a/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx +++ b/apps/embed/app/view/[videoId]/_components/ShareVideo.tsx @@ -83,29 +83,38 @@ export const ShareVideo = ({ useEffect(() => { if (videoMetadataLoaded) { - console.log("Metadata loaded"); setIsLoading(false); } }, [videoMetadataLoaded]); useEffect(() => { const onVideoLoadedMetadata = () => { - console.log("Video metadata loaded"); - setVideoMetadataLoaded(true); if (videoRef.current) { setLongestDuration(videoRef.current.duration); + setVideoMetadataLoaded(true); + setIsLoading(false); } }; - const videoElement = videoRef.current; + const onCanPlay = () => { + setVideoMetadataLoaded(true); + setIsLoading(false); + }; - videoElement?.addEventListener("loadedmetadata", onVideoLoadedMetadata); + const videoElement = videoRef.current; + if (videoElement) { + videoElement.addEventListener("loadedmetadata", onVideoLoadedMetadata); + videoElement.addEventListener("canplay", onCanPlay); + } return () => { - videoElement?.removeEventListener( - "loadedmetadata", - onVideoLoadedMetadata - ); + if (videoElement) { + videoElement.removeEventListener( + "loadedmetadata", + onVideoLoadedMetadata + ); + videoElement.removeEventListener("canplay", onCanPlay); + } }; }, []); diff --git a/apps/embed/next.config.mjs b/apps/embed/next.config.mjs index 488d3b21..d360e34a 100644 --- a/apps/embed/next.config.mjs +++ b/apps/embed/next.config.mjs @@ -34,25 +34,7 @@ const nextConfig = { remotePatterns: [ { protocol: "https", - hostname: "*.amazonaws.com", - port: "", - pathname: "**", - }, - { - protocol: "https", - hostname: "*.cloudfront.net", - port: "", - pathname: "**", - }, - { - protocol: "https", - hostname: "*v.cap.so", - port: "", - pathname: "**", - }, - { - protocol: "https", - hostname: "*tasks.cap.so", + hostname: "**", port: "", pathname: "**", }, diff --git a/apps/web/app/api/playlist/route.ts b/apps/web/app/api/playlist/route.ts index 9b5e0460..c21841f6 100644 --- a/apps/web/app/api/playlist/route.ts +++ b/apps/web/app/api/playlist/route.ts @@ -78,6 +78,37 @@ export async function GET(request: NextRequest) { } } + if (!bucket || video.awsBucket === process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) { + if (video.source.type === "desktopMP4") { + return new Response(null, { + status: 302, + headers: { + ...getHeaders(origin), + Location: `https://v.cap.so/${userId}/${videoId}/result.mp4`, + }, + }); + } + + if (video.source.type === "MediaConvert") { + return new Response(null, { + status: 302, + headers: { + ...getHeaders(origin), + Location: `https://v.cap.so/${userId}/${videoId}/output/video_recording_000.m3u8`, + }, + }); + } + + const playlistUrl = `https://v.cap.so/${userId}/${videoId}/combined-source/stream.m3u8`; + return new Response(null, { + status: 302, + headers: { + ...getHeaders(origin), + Location: playlistUrl, + }, + }); + } + const Bucket = getS3Bucket(bucket); const videoPrefix = `${userId}/${videoId}/video/`; const audioPrefix = `${userId}/${videoId}/audio/`; @@ -129,7 +160,6 @@ export async function GET(request: NextRequest) { { expiresIn: 3600 } ); - console.log({ playlistUrl }); return new Response(null, { status: 302, headers: { diff --git a/apps/web/app/api/screenshot/route.ts b/apps/web/app/api/screenshot/route.ts index 167a6ab8..4390806c 100644 --- a/apps/web/app/api/screenshot/route.ts +++ b/apps/web/app/api/screenshot/route.ts @@ -2,12 +2,13 @@ import { type NextRequest } from "next/server"; import { db } from "@cap/database"; import { s3Buckets, videos } from "@cap/database/schema"; import { eq } from "drizzle-orm"; -import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3"; +import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3"; import { getCurrentUser } from "@cap/database/auth/session"; import { getHeaders } from "@/utils/helpers"; import { createS3Client, getS3Bucket } from "@/utils/s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -export const revalidate = 3599; +export const revalidate = 0; export async function OPTIONS(request: NextRequest) { const origin = request.headers.get("origin") as string; @@ -47,7 +48,15 @@ export async function GET(request: NextRequest) { ); } - const { video, bucket } = query[0]; + const result = query[0]; + if (!result?.video || !result?.bucket) { + return new Response( + JSON.stringify({ error: true, message: "Video or bucket not found" }), + { status: 401, headers: getHeaders(origin) } + ); + } + + const { video, bucket } = result; if (video.public === false) { const user = await getCurrentUser(); @@ -85,7 +94,20 @@ export async function GET(request: NextRequest) { ); } - const screenshotUrl = `https://v.cap.so/${screenshot.Key}`; + let screenshotUrl: string; + + if (video.awsBucket !== process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) { + screenshotUrl = await getSignedUrl( + s3Client, + new GetObjectCommand({ + Bucket, + Key: screenshot.Key + }), + { expiresIn: 3600 } + ); + } else { + screenshotUrl = `https://v.cap.so/${screenshot.Key}`; + } return new Response(JSON.stringify({ url: screenshotUrl }), { status: 200, diff --git a/apps/web/app/api/thumbnail/route.ts b/apps/web/app/api/thumbnail/route.ts index bbfb3ac0..be5afb63 100644 --- a/apps/web/app/api/thumbnail/route.ts +++ b/apps/web/app/api/thumbnail/route.ts @@ -1,12 +1,13 @@ import type { NextRequest } from "next/server"; -import { ListObjectsV2Command } from "@aws-sdk/client-s3"; +import { ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3"; import { getHeaders } from "@/utils/helpers"; import { db } from "@cap/database"; import { eq } from "drizzle-orm"; import { s3Buckets, videos } from "@cap/database/schema"; import { createS3Client, getS3Bucket } from "@/utils/s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -export const revalidate = 3500; +export const revalidate = 0; export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; @@ -46,12 +47,33 @@ export async function GET(request: NextRequest) { ); } - const { video, bucket } = query[0]; - const Bucket = getS3Bucket(bucket); + const result = query[0]; + if (!result?.video) { + return new Response( + JSON.stringify({ error: true, message: "Video not found" }), + { + status: 401, + headers: getHeaders(origin), + } + ); + } - const s3Client = createS3Client(bucket); + const { video } = result; const prefix = `${userId}/${videoId}/`; + let thumbnailUrl: string; + + if (!result.bucket || video.awsBucket === process.env.NEXT_PUBLIC_CAP_AWS_BUCKET) { + thumbnailUrl = `https://v.cap.so/${prefix}screenshot/screen-capture.jpg`; + return new Response(JSON.stringify({ screen: thumbnailUrl }), { + status: 200, + headers: getHeaders(origin), + }); + } + + const Bucket = getS3Bucket(result.bucket); + const s3Client = createS3Client(result.bucket); + try { const listCommand = new ListObjectsV2Command({ Bucket, @@ -61,24 +83,37 @@ export async function GET(request: NextRequest) { const listResponse = await s3Client.send(listCommand); const contents = listResponse.Contents || []; - let thumbnailKey = contents.find((item) => item.Key?.endsWith(".png"))?.Key; + const thumbnailKey = contents.find((item) => + item.Key?.endsWith("screen-capture.jpg") + )?.Key; if (!thumbnailKey) { - thumbnailKey = `${prefix}screenshot/screen-capture.jpg`; + return new Response( + JSON.stringify({ + error: true, + message: "No thumbnail found for this video", + }), + { + status: 404, + headers: getHeaders(origin), + } + ); } - const thumbnailUrl = `https://v.cap.so/${thumbnailKey}`; - - return new Response(JSON.stringify({ screen: thumbnailUrl }), { - status: 200, - headers: getHeaders(origin), - }); + thumbnailUrl = await getSignedUrl( + s3Client, + new GetObjectCommand({ + Bucket, + Key: thumbnailKey + }), + { expiresIn: 3600 } + ); } catch (error) { - console.error("Error generating thumbnail URL:", error); return new Response( JSON.stringify({ error: true, message: "Error generating thumbnail URL", + details: error instanceof Error ? error.message : "Unknown error", }), { status: 500, @@ -86,4 +121,9 @@ export async function GET(request: NextRequest) { } ); } + + return new Response(JSON.stringify({ screen: thumbnailUrl }), { + status: 200, + headers: getHeaders(origin), + }); } diff --git a/apps/web/app/api/video/[videoId]/route.ts b/apps/web/app/api/video/[videoId]/route.ts new file mode 100644 index 00000000..e2290f0c --- /dev/null +++ b/apps/web/app/api/video/[videoId]/route.ts @@ -0,0 +1,41 @@ +import { db } from "@cap/database"; +import { s3Buckets, videos } from "@cap/database/schema"; +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +export async function GET( + request: Request, + { params }: { params: { videoId: string } } +) { + const videoId = params.videoId; + + const query = await db + .select({ + video: videos, + bucket: s3Buckets, + }) + .from(videos) + .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) + .where(eq(videos.id, videoId)); + + if (query.length === 0) { + return NextResponse.json({ error: "Video not found" }, { status: 404 }); + } + + const result = query[0]; + if (!result?.video) { + return NextResponse.json({ error: "Video not found" }, { status: 404 }); + } + + const defaultBucket = { + name: process.env.NEXT_PUBLIC_CAP_AWS_BUCKET, + region: process.env.NEXT_PUBLIC_CAP_AWS_REGION, + accessKeyId: process.env.CAP_AWS_ACCESS_KEY, + secretAccessKey: process.env.CAP_AWS_SECRET_KEY, + }; + + return NextResponse.json({ + video: result.video, + bucket: result.bucket || defaultBucket, + }); +} \ No newline at end of file diff --git a/apps/web/app/api/video/og/route.tsx b/apps/web/app/api/video/og/route.tsx index 2c7c256b..5ac8459b 100644 --- a/apps/web/app/api/video/og/route.tsx +++ b/apps/web/app/api/video/og/route.tsx @@ -1,6 +1,3 @@ -import { db } from "@cap/database"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { eq } from "drizzle-orm"; import { ImageResponse } from "next/og"; import { NextRequest } from "next/server"; import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; @@ -11,25 +8,18 @@ export const runtime = "edge"; export async function GET(req: NextRequest) { const videoId = req.nextUrl.searchParams.get("videoId") as string; - const query = await db - .select({ - video: videos, - bucket: s3Buckets, - }) - .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) - .where(eq(videos.id, videoId)); - type FileKey = { - type: "screen"; - key: string; - }; - - type ResponseObject = { - screen: string | null; - }; + const response = await fetch( + `${process.env.NEXT_PUBLIC_URL}/api/video/${videoId}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); - if (query.length === 0 || query?.[0]?.video.public === false) { + if (!response.ok) { return new ImageResponse( (
-

Video not found

+

Cap not found

+

+ The video you are looking for does not exist or has moved. +

), { @@ -53,37 +47,47 @@ export async function GET(req: NextRequest) { ); } - const { video, bucket } = query[0]; + const { video, bucket } = await response.json(); - const s3Client = createS3Client(bucket); + if (!video || !bucket || video.public === false) { + return new ImageResponse( + ( +
+

Video or bucket not found

+
+ ), + { + width: 1200, + height: 630, + } + ); + } + const s3Client = createS3Client(bucket); const Bucket = getS3Bucket(bucket); - const fileKeys: FileKey[] = [ - { - type: "screen", - key: `${video.ownerId}/${video.id}/screenshot/screen-capture.jpg`, - }, - ]; - const responseObject: ResponseObject = { - screen: null, - }; + const screenshotKey = `${video.ownerId}/${video.id}/screenshot/screen-capture.jpg`; + let screenshotUrl = null; - await Promise.all( - fileKeys.map(async ({ type, key }) => { - try { - const url = await getSignedUrl( - s3Client, - new GetObjectCommand({ Bucket, Key: key }), - { expiresIn: 3600 } - ); - responseObject[type] = url; - } catch (error) { - console.error("Error generating URL for:", key, error); - responseObject[type] = null; - } - }) - ); + try { + screenshotUrl = await getSignedUrl( + s3Client, + new GetObjectCommand({ Bucket, Key: screenshotKey }), + { expiresIn: 3600 } + ); + } catch (error) { + console.error("Error generating URL for screenshot:", error); + } return new ImageResponse( ( @@ -95,7 +99,7 @@ export async function GET(req: NextRequest) { alignItems: "center", justifyContent: "center", background: - "radial-gradient(90.01% 80.01% at 53.53% 49.99%,#d3e5ff 30.65%,#82c6f1 88.48%,#fff 100%)", + "radial-gradient(90.01% 80.01% at 53.53% 49.99%,#d3e5ff 30.65%,#4785ff 88.48%,#fff 100%)", }} >
- {responseObject.screen && ( + {screenshotUrl && ( )}
diff --git a/apps/web/app/s/[videoId]/_components/MP4VideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/MP4VideoPlayer.tsx index 266bbf6e..4c8e0990 100644 --- a/apps/web/app/s/[videoId]/_components/MP4VideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/MP4VideoPlayer.tsx @@ -21,24 +21,26 @@ export const MP4VideoPlayer = memo( const video = videoRef.current; if (!video) return; - const startTime = Date.now(); - const maxDuration = 2 * 60 * 1000; - - const checkAndReload = () => { - if (video.readyState === 0) { - // HAVE_NOTHING - video.load(); - } - - if (Date.now() - startTime < maxDuration) { - setTimeout(checkAndReload, 3000); - } + const handleLoadedMetadata = () => { + // Trigger a canplay event after metadata is loaded + video.dispatchEvent(new Event("canplay")); }; - checkAndReload(); + const handleError = (e: ErrorEvent) => { + console.error("Video loading error:", e); + // Attempt to reload on error + video.load(); + }; + + video.addEventListener("loadedmetadata", handleLoadedMetadata); + video.addEventListener("error", handleError as EventListener); + + // Initial load + video.load(); return () => { - clearTimeout(checkAndReload as unknown as number); + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + video.removeEventListener("error", handleError as EventListener); }; }, [videoSrc]); @@ -47,7 +49,7 @@ export const MP4VideoPlayer = memo( id="video-player" ref={videoRef} className="w-full h-full object-contain" - preload="metadata" + preload="auto" playsInline controls={false} muted diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index ac130c63..782d6032 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -139,21 +139,32 @@ export const ShareVideo = ({ useEffect(() => { const onVideoLoadedMetadata = () => { - setVideoMetadataLoaded(true); if (videoRef.current) { setLongestDuration(videoRef.current.duration); + setVideoMetadataLoaded(true); + setIsLoading(false); } }; - const videoElement = videoRef.current; + const onCanPlay = () => { + setVideoMetadataLoaded(true); + setIsLoading(false); + }; - videoElement?.addEventListener("loadedmetadata", onVideoLoadedMetadata); + const videoElement = videoRef.current; + if (videoElement) { + videoElement.addEventListener("loadedmetadata", onVideoLoadedMetadata); + videoElement.addEventListener("canplay", onCanPlay); + } return () => { - videoElement?.removeEventListener( - "loadedmetadata", - onVideoLoadedMetadata - ); + if (videoElement) { + videoElement.removeEventListener( + "loadedmetadata", + onVideoLoadedMetadata + ); + videoElement.removeEventListener("canplay", onCanPlay); + } }; }, []); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 583d784d..4c2c888c 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -38,25 +38,7 @@ const nextConfig = { remotePatterns: [ { protocol: "https", - hostname: "*.amazonaws.com", - port: "", - pathname: "**", - }, - { - protocol: "https", - hostname: "*.cloudfront.net", - port: "", - pathname: "**", - }, - { - protocol: "https", - hostname: "*v.cap.so", - port: "", - pathname: "**", - }, - { - protocol: "https", - hostname: "*tasks.cap.so", + hostname: "**", port: "", pathname: "**", },