diff --git a/apps/web/app/(org)/dashboard/caps/Caps.tsx b/apps/web/app/(org)/dashboard/caps/Caps.tsx
index d6721cded0..2af455a53c 100644
--- a/apps/web/app/(org)/dashboard/caps/Caps.tsx
+++ b/apps/web/app/(org)/dashboard/caps/Caps.tsx
@@ -12,6 +12,10 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { ChromeRecorderButton } from "@/components/ChromeRecorderButton";
+import {
+ type SeededThumbnails,
+ SeededThumbnailsProvider,
+} from "@/components/VideoThumbnail";
import { CHROME_EXTENSION_BUTTON_CLASS } from "@/lib/chrome-extension";
import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime";
import { useVideosAnalyticsQuery } from "@/lib/Queries/Analytics";
@@ -70,11 +74,13 @@ export const Caps = ({
count,
analyticsEnabled,
folders,
+ thumbnails,
}: {
data: VideoData;
count: number;
folders: FolderDataType[];
analyticsEnabled: boolean;
+ thumbnails?: SeededThumbnails;
}) => {
const router = useRouter();
const params = useSearchParams();
@@ -286,36 +292,38 @@ export const Caps = ({
-
- {isUploading && (
-
- )}
- {visibleVideos.map((video) => {
- const videoAnalytics = analytics[video.id];
- return (
- {
- if (selectedCaps.length > 0) {
- deleteCaps(selectedCaps);
- } else {
- deleteCap(video.id);
- }
- }}
- userId={user?.id}
- isLoadingAnalytics={isLoadingAnalytics}
- isSelected={selectedCaps.includes(video.id)}
- anyCapSelected={anyCapSelected}
- onSelectToggle={() => handleCapSelection(video.id)}
- canMove
- moveLocation={moveLocation}
- moveRootLabel="My Caps"
- />
- );
- })}
-
+
+
+ {isUploading && (
+
+ )}
+ {visibleVideos.map((video) => {
+ const videoAnalytics = analytics[video.id];
+ return (
+ {
+ if (selectedCaps.length > 0) {
+ deleteCaps(selectedCaps);
+ } else {
+ deleteCap(video.id);
+ }
+ }}
+ userId={user?.id}
+ isLoadingAnalytics={isLoadingAnalytics}
+ isSelected={selectedCaps.includes(video.id)}
+ anyCapSelected={anyCapSelected}
+ onSelectToggle={() => handleCapSelection(video.id)}
+ canMove
+ moveLocation={moveLocation}
+ moveRootLabel="My Caps"
+ />
+ );
+ })}
+
+
>
)}
{(data.length > limit || data.length === limit || page !== 1) && (
diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx
index 52fcf06a30..51696e1eb1 100644
--- a/apps/web/app/(org)/dashboard/caps/page.tsx
+++ b/apps/web/app/(org)/dashboard/caps/page.tsx
@@ -22,6 +22,7 @@ import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { type Array, Effect } from "effect";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
+import { seedThumbnailUrls } from "@/lib/seeded-thumbnails";
import { runPromise } from "@/lib/server";
import { Caps } from "./Caps";
@@ -272,6 +273,12 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) {
const organizationSettings = organizationSettingsRow?.settings ?? null;
const videoIds = videoData.map((video) => video.id);
+ const thumbnails = seedThumbnailUrls(
+ user,
+ videoData
+ .filter((video) => !video.hasActiveUpload)
+ .map((video) => Video.VideoId.make(video.id)),
+ );
const sharedSpacesMap =
await getSharedSpacesForVideos(videoIds).pipe(runPromise);
@@ -340,6 +347,7 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) {
data={processedVideoData}
folders={foldersData}
count={totalCount}
+ thumbnails={thumbnails}
analyticsEnabled={Boolean(
serverEnv().TINYBIRD_TOKEN && serverEnv().TINYBIRD_HOST,
)}
diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx
index b2079583bd..c2bc3d0196 100644
--- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx
+++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx
@@ -19,6 +19,10 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
+import {
+ type SeededThumbnails,
+ SeededThumbnailsProvider,
+} from "@/components/VideoThumbnail";
import {
canManageOrganizationMembers,
canManageSpace,
@@ -99,6 +103,7 @@ export const SharedCaps = ({
folders,
analyticsEnabled,
organizationData,
+ thumbnails,
}: {
data: SharedVideoData;
count: number;
@@ -115,6 +120,7 @@ export const SharedCaps = ({
name: string;
ownerId: User.UserId;
};
+ thumbnails?: SeededThumbnails;
}) => {
const params = useSearchParams();
const pathname = usePathname();
@@ -434,39 +440,44 @@ export const SharedCaps = ({
Videos and screenshots
-
- {data.map((cap) => {
- return (
- 0
- }
- onSelectToggle={
- canManageCurrentSharedCollection
- ? () => handleCapSelection(cap.id)
- : undefined
- }
- onDragStart={() => setIsDraggingCap(true)}
- onDragEnd={() => setIsDraggingCap(false)}
- />
- );
- })}
-
+
+
+ {data.map((cap) => {
+ return (
+ 0
+ }
+ onSelectToggle={
+ canManageCurrentSharedCollection
+ ? () => handleCapSelection(cap.id)
+ : undefined
+ }
+ onDragStart={() => setIsDraggingCap(true)}
+ onDragEnd={() => setIsDraggingCap(false)}
+ />
+ );
+ })}
+
+
{(data.length > limit || data.length === limit || page !== 1) && (
!video.hasActiveUpload)
+ .map((video) => Video.VideoId.make(video.id)),
+ );
const resolvedSpace = await Effect.gen(function* () {
const imageUploads = yield* ImageUploads;
return {
@@ -439,6 +446,7 @@ export default async function SharedCapsPage(props: {
organizationMembers={organizationMembersData}
currentUserId={user.id}
folders={foldersData}
+ thumbnails={thumbnails}
/>
);
}
@@ -529,6 +537,12 @@ export default async function SharedCapsPage(props: {
fetchFolders(organization.id, true),
fetchOrganizationSettings(organization.id),
]);
+ const thumbnails = seedThumbnailUrls(
+ user,
+ orgVideoData
+ .filter((video) => !video.hasActiveUpload)
+ .map((video) => Video.VideoId.make(video.id)),
+ );
const sharedSpacesMap = await fetchSharedSpacesForVideos(
orgVideoData.map((video) => Video.VideoId.make(video.id)),
{ userId: user.id, organizationId: organization.id },
@@ -571,6 +585,7 @@ export default async function SharedCapsPage(props: {
organizationMembers={organizationMembersData}
currentUserId={user.id}
folders={foldersData}
+ thumbnails={thumbnails}
/>
);
}
diff --git a/apps/web/components/VideoThumbnail.tsx b/apps/web/components/VideoThumbnail.tsx
index 3e97b37caf..88b96d0c01 100644
--- a/apps/web/components/VideoThumbnail.tsx
+++ b/apps/web/components/VideoThumbnail.tsx
@@ -3,14 +3,45 @@ import type { Video } from "@cap/web-domain";
import clsx from "clsx";
import { Effect } from "effect";
import Image from "next/image";
-import type { CSSProperties } from "react";
-import { memo, useEffect, useRef, useState } from "react";
+import type { CSSProperties, ReactNode } from "react";
+import {
+ createContext,
+ memo,
+ Suspense,
+ use,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
import { durationParts } from "@/lib/date-format";
import { useEffectQuery } from "@/lib/EffectRuntime";
import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest";
export type ImageLoadingStatus = "loading" | "success" | "error";
+export type SeededThumbnails = {
+ videoIds: readonly Video.VideoId[];
+ urls: Promise>;
+};
+
+const SeededThumbnailsContext = createContext(null);
+
+export function SeededThumbnailsProvider({
+ value,
+ children,
+}: {
+ value: SeededThumbnails | undefined;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
type PreviewState = {
videoId: Video.VideoId;
hovered: boolean;
@@ -61,9 +92,14 @@ function getPreviewGifSrc(videoId: Video.VideoId) {
return `/api/video/preview?videoId=${encodeURIComponent(videoId)}&fallback=none`;
}
+// Signed thumbnail URLs last an hour. A seeded URL skips the refetch on mount,
+// and is re-signed on later mounts once it is older than this, as before.
+const SEEDED_THUMBNAIL_STALE_TIME = 5 * 60 * 1000;
+
export const useThumnailQuery = (
videoId: Video.VideoId,
enabled: boolean = true,
+ seededUrl?: string,
) => {
return useEffectQuery({
queryKey: ThumbnailRequest.queryKey(videoId),
@@ -74,10 +110,44 @@ export const useThumnailQuery = (
);
}),
enabled,
+ initialData: seededUrl,
+ staleTime: seededUrl === undefined ? 0 : SEEDED_THUMBNAIL_STALE_TIME,
});
};
-export const VideoThumbnail: React.FC = memo(
+const subscribeToNothing = () => () => {};
+
+export const VideoThumbnail: React.FC = memo((props) => {
+ const seeded = useContext(SeededThumbnailsContext);
+ // The server HTML keeps the loading state and the image only mounts after
+ // hydration, as it did with the client RPC. Rendering it in the HTML made the
+ // thumbnails compete with the page's JS for bandwidth and delayed hydration.
+ const hydrated = useSyncExternalStore(
+ subscribeToNothing,
+ () => true,
+ () => false,
+ );
+ if (!seeded || !seeded.videoIds.includes(props.videoId)) {
+ return ;
+ }
+ const pending = ;
+ if (!hydrated) return pending;
+ return (
+
+
+
+ );
+});
+
+function SeededVideoThumbnail({
+ urls,
+ ...props
+}: VideoThumbnailProps & { urls: SeededThumbnails["urls"] }) {
+ const seededUrl = use(urls)[props.videoId];
+ return ;
+}
+
+const VideoThumbnailView = memo(
({
videoId,
alt,
@@ -89,8 +159,14 @@ export const VideoThumbnail: React.FC = memo(
setImageStatus,
hasActiveUpload = false,
showPreview = true,
- }) => {
- const thumbnailUrl = useThumnailQuery(videoId, !hasActiveUpload);
+ awaitingSeed = false,
+ seededUrl,
+ }: VideoThumbnailProps & { awaitingSeed?: boolean; seededUrl?: string }) => {
+ const thumbnailUrl = useThumnailQuery(
+ videoId,
+ !hasActiveUpload && !awaitingSeed,
+ seededUrl,
+ );
const containerRef = useRef(null);
const imageRef = useRef(null);
const latestVideoId = useRef(videoId);
diff --git a/apps/web/lib/seeded-thumbnails.ts b/apps/web/lib/seeded-thumbnails.ts
new file mode 100644
index 0000000000..c5f9de73cb
--- /dev/null
+++ b/apps/web/lib/seeded-thumbnails.ts
@@ -0,0 +1,39 @@
+import { makeCurrentUserLayer, Videos } from "@cap/web-backend";
+import type { Video } from "@cap/web-domain";
+import { Effect, Option } from "effect";
+import type { SeededThumbnails } from "@/components/VideoThumbnail";
+import { runPromise } from "@/lib/server";
+
+// Matches the VideosGetThumbnails batch limit. Pages take a `limit` query
+// parameter, so ids past this fall back to the client RPC.
+const MAX_SEEDED_THUMBNAILS = 50;
+
+export function seedThumbnailUrls(
+ user: Parameters[0],
+ allVideoIds: readonly Video.VideoId[],
+): SeededThumbnails | undefined {
+ const videoIds = allVideoIds.slice(0, MAX_SEEDED_THUMBNAILS);
+ if (videoIds.length === 0) return undefined;
+
+ const urls = Effect.gen(function* () {
+ const videos = yield* Videos;
+ const results = yield* Effect.forEach(
+ videoIds,
+ (videoId) => videos.getThumbnailURL(videoId).pipe(Effect.option),
+ { concurrency: 10 },
+ );
+
+ const seeded: Record = {};
+ results.forEach((result, index) => {
+ const url = Option.flatten(result);
+ const videoId = videoIds[index];
+ if (videoId && Option.isSome(url)) seeded[videoId] = url.value;
+ });
+ return seeded;
+ }).pipe(Effect.provide(makeCurrentUserLayer(user)), runPromise);
+
+ return {
+ videoIds,
+ urls: urls.catch(() => ({})),
+ };
+}