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