diff --git a/apps/web/actions/videos/new-comment.ts b/apps/web/actions/videos/new-comment.ts index b20d6128c2..82d6cc568c 100644 --- a/apps/web/actions/videos/new-comment.ts +++ b/apps/web/actions/videos/new-comment.ts @@ -13,6 +13,7 @@ export async function newComment(data: { videoId: Video.VideoId; type: "text" | "emoji"; parentCommentId: string; + timestamp: number; }) { const user = await getCurrentUser(); @@ -24,6 +25,7 @@ export async function newComment(data: { const videoId = data.videoId; const type = data.type; const parentCommentId = data.parentCommentId; + const timestamp = data.timestamp; const conditionalType = parentCommentId ? "reply" : type === "emoji" @@ -41,7 +43,7 @@ export async function newComment(data: { type: type, content: content, videoId: videoId, - timestamp: null, + timestamp: timestamp || null, parentCommentId: parentCommentId, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index b17c9ac84e..eaa8988bb6 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -92,6 +92,9 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const router = useRouter(); const isPathActive = (path: string) => pathname.includes(path); + const isDomainSetupVerified = + activeOrg?.organization.customDomain && + activeOrg?.organization.domainVerified; return ( @@ -179,33 +182,24 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { {!sidebarCollapsed && (

- {activeOrg?.organization.customDomain && - activeOrg?.organization.domainVerified + {isDomainSetupVerified ? activeOrg?.organization.customDomain : "No custom domain set"}

diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 28602e4217..1b675b8783 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -213,15 +213,64 @@ export const Share = ({ const aiLoading = shouldShowLoading(); - const handleSeek = (time: number) => { - if (playerRef.current) { - playerRef.current.currentTime = time; + const handleSeek = useCallback((time: number) => { + let video = playerRef.current; + + // Fallback to DOM query if ref is not available + if (!video) { + video = document.querySelector("video") as HTMLVideoElement; + if (!video) { + console.warn("Video player not ready"); + return; + } } - }; + + // Try to seek immediately if video has metadata + if (video.readyState >= 1) { + // HAVE_METADATA + try { + video.currentTime = time; + return; + } catch (error) { + console.error("Failed to seek video:", error); + } + } + + // If video isn't ready, wait for it to be ready + const handleCanPlay = () => { + try { + video.currentTime = time; + } catch (error) { + console.error("Failed to seek video after canplay:", error); + } + video.removeEventListener("canplay", handleCanPlay); + }; + + const handleLoadedMetadata = () => { + try { + video.currentTime = time; + } catch (error) { + console.error("Failed to seek video after loadedmetadata:", error); + } + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + }; + + // Listen for multiple events to ensure we catch when the video is ready + video.addEventListener("canplay", handleCanPlay); + video.addEventListener("loadedmetadata", handleLoadedMetadata); + + // Cleanup after 3 seconds if events don't fire + setTimeout(() => { + video.removeEventListener("canplay", handleCanPlay); + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + }, 3000); + }, []); const handleOptimisticComment = useCallback( (comment: CommentType) => { - setOptimisticComments(comment); + startTransition(() => { + setOptimisticComments(comment); + }); setTimeout(() => { activityRef.current?.scrollToBottom(); }, 100); diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 9eae370b6a..7422d5912b 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -8,6 +8,7 @@ import clsx from "clsx"; import { AnimatePresence, motion } from "framer-motion"; import { AlertTriangleIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import CommentStamp from "./CommentStamp"; import ProgressCircle, { useUploadProgress } from "./ProgressCircle"; import { MediaPlayer, @@ -39,6 +40,14 @@ interface Props { autoplay?: boolean; enableCrossOrigin?: boolean; hasActiveUpload: boolean | undefined; + comments?: Array<{ + id: string; + timestamp: number | null; + type: "text" | "emoji"; + content: string; + authorName?: string | null; + }>; + onSeek?: (time: number) => void; } export function CapVideoPlayer({ @@ -51,9 +60,12 @@ export function CapVideoPlayer({ autoplay = false, enableCrossOrigin = false, hasActiveUpload, + comments = [], + onSeek, }: Props) { const [currentCue, setCurrentCue] = useState(""); const [controlsVisible, setControlsVisible] = useState(false); + const [mainControlsVisible, setMainControlsVisible] = useState(false); const [toggleCaptions, setToggleCaptions] = useState(true); const [showPlayButton, setShowPlayButton] = useState(false); const [videoLoaded, setVideoLoaded] = useState(false); @@ -69,6 +81,7 @@ export function CapVideoPlayer({ const [isRetrying, setIsRetrying] = useState(false); const isRetryingRef = useRef(false); const maxRetries = 3; + const [duration, setDuration] = useState(0); useEffect(() => { const checkMobile = () => { @@ -218,6 +231,42 @@ export function CapVideoPlayer({ fetchNewUrl(); }, [fetchNewUrl]); + // Track video duration for comment markers + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handleLoadedMetadata = () => { + setDuration(video.duration); + }; + + video.addEventListener("loadedmetadata", handleLoadedMetadata); + + return () => { + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + }; + }, [urlResolved]); + + // Track when all data is ready for comment markers + const [markersReady, setMarkersReady] = useState(false); + const [hoveredComment, setHoveredComment] = useState(null); + + // Memoize hover handlers to prevent render loops + const handleMouseEnter = useCallback((commentId: string) => { + setHoveredComment(commentId); + }, []); + + const handleMouseLeave = useCallback(() => { + setHoveredComment(null); + }, []); + + useEffect(() => { + // Only show markers when we have duration, comments, and video element + if (duration > 0 && comments.length > 0 && videoRef.current) { + setMarkersReady(true); + } + }, [duration, comments.length]); + useEffect(() => { const video = videoRef.current; if (!video || !urlResolved) return; @@ -484,13 +533,15 @@ export function CapVideoPlayer({ playsInline autoPlay={autoplay} > - - + {chaptersSrc && } + {captionsSrc && ( + + )} )} @@ -539,7 +590,7 @@ export function CapVideoPlayer({ "absolute left-1/2 transform -translate-x-1/2 text-sm sm:text-xl z-40 pointer-events-none bg-black/80 text-white px-3 sm:px-4 py-1.5 sm:py-2 rounded-md text-center transition-all duration-300 ease-in-out", "max-w-[90%] sm:max-w-[480px] md:max-w-[600px]", controlsVisible || videoRef.current?.paused - ? "bottom-16 sm:bottom-20" + ? "bottom-16 sm:bottom-24" : "bottom-3 sm:bottom-12", )} > @@ -551,8 +602,35 @@ export function CapVideoPlayer({ )} + + {mainControlsVisible && + markersReady && + comments + .filter( + (comment) => comment && comment.timestamp !== null && comment.id, + ) + .map((comment) => { + const position = (Number(comment.timestamp) / duration) * 100; + const containerPadding = 20; + const availableWidth = `calc(100% - ${containerPadding * 2}px)`; + const adjustedPosition = `calc(${containerPadding}px + (${position}% * ${availableWidth} / 100%))`; + + return ( + + ); + })} + setMainControlsVisible(arg)} isUploadingOrFailed={isUploading || isUploadFailed} > diff --git a/apps/web/app/s/[videoId]/_components/CommentStamp.tsx b/apps/web/app/s/[videoId]/_components/CommentStamp.tsx new file mode 100644 index 0000000000..8492839f56 --- /dev/null +++ b/apps/web/app/s/[videoId]/_components/CommentStamp.tsx @@ -0,0 +1,85 @@ +import { Avatar } from "@cap/ui"; +import { faComment } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +interface CommentStampsProps { + comment: { + id: string; + timestamp: number | null; + type: "text" | "emoji"; + content: string; + authorName?: string | null; + }; + adjustedPosition: string; + handleMouseEnter: (id: string) => void; + handleMouseLeave: () => void; + onSeek: ((time: number) => void) | undefined; + hoveredComment: string | null; +} + +const CommentStamp: React.FC = ({ + comment, + adjustedPosition, + handleMouseEnter, + handleMouseLeave, + onSeek, + hoveredComment, +}: CommentStampsProps) => { + return ( +
handleMouseEnter(comment.id)} + onMouseLeave={handleMouseLeave} + > + {/* Comment marker */} + + + {hoveredComment === comment.id && ( +
+ {/* Arrow pointing down to marker */} +
+ +
+ {/* User avatar/initial */} + + {/* Comment content */} +
+
+ {comment.authorName || "Anonymous"} +
+
+ {comment.content} +
+
+
+
+ )} +
+ ); +}; + +export default CommentStamp; diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index e38a8aba4d..30e0b78566 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -42,20 +42,39 @@ export const ShareVideo = forwardRef< chapters?: { title: string; start: number }[]; aiProcessing?: boolean; } ->(({ data, user, comments, chapters = [], aiProcessing = false }, ref) => { +>(({ data, comments, chapters = [] }, ref) => { const videoRef = useRef(null); - useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement); + useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement, []); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [transcriptData, setTranscriptData] = useState([]); const [subtitleUrl, setSubtitleUrl] = useState(null); const [chaptersUrl, setChaptersUrl] = useState(null); + const [commentsData, setCommentsData] = useState([]); const { data: transcriptContent, error: transcriptError } = useTranscript( data.id, data.transcriptionStatus, ); + // Handle comments data + useEffect(() => { + if (comments) { + if (Array.isArray(comments)) { + setCommentsData(comments); + } else { + comments.then(setCommentsData); + } + } + }, [comments]); + + // Handle seek functionality + const handleSeek = (time: number) => { + if (videoRef.current) { + videoRef.current.currentTime = time; + } + }; + useEffect(() => { if (transcriptContent) { const parsed = parseVTT(transcriptContent); @@ -156,6 +175,14 @@ export const ShareVideo = forwardRef< videoRef={videoRef} enableCrossOrigin={enableCrossOrigin} hasActiveUpload={data.hasActiveUpload} + comments={commentsData.map((comment) => ({ + id: comment.id, + type: comment.type, + timestamp: comment.timestamp, + content: comment.content, + authorName: comment.authorName, + }))} + onSeek={handleSeek} /> ) : ( ( - null, - ); - - useEffect(() => { - const checkForVideoElement = () => { - const element = document.getElementById( - "video-player", - ) as HTMLVideoElement | null; - if (element) { - setVideoElement(element); - } else { - setTimeout(checkForVideoElement, 100); // Check again after 100ms - } - }; - - checkForVideoElement(); - }, []); - - const getTimestamp = (): number => { - if (videoElement) { - return videoElement.currentTime; - } - console.warn("Video element not available, using default timestamp"); - return 0; - }; const handleEmojiClick = async (emoji: string) => { + const videoElement = document.querySelector("video") as HTMLVideoElement; + const currentTime = videoElement?.currentTime || 0; const optimisticComment: CommentType = { id: `temp-${Date.now()}`, authorId: user?.id || "anonymous", @@ -63,7 +39,7 @@ export const Toolbar = ({ videoId: data.id, parentCommentId: "", type: "emoji", - timestamp: null, + timestamp: currentTime, updatedAt: new Date(), sending: true, }; @@ -76,6 +52,7 @@ export const Toolbar = ({ videoId: data.id, parentCommentId: "", type: "emoji", + timestamp: currentTime, }); startTransition(() => { onCommentSuccess?.(newCommentData); @@ -92,7 +69,8 @@ export const Toolbar = ({ if (comment.length === 0) { return; } - + const videoElement = document.querySelector("video") as HTMLVideoElement; + const currentTime = videoElement?.currentTime || 0; const optimisticComment: CommentType = { id: `temp-${Date.now()}`, authorId: user?.id || "anonymous", @@ -102,7 +80,7 @@ export const Toolbar = ({ videoId: data.id, parentCommentId: "", type: "text", - timestamp: null, + timestamp: currentTime, updatedAt: new Date(), sending: true, }; @@ -115,6 +93,7 @@ export const Toolbar = ({ videoId: data.id, parentCommentId: "", type: "text", + timestamp: currentTime, }); startTransition(() => { onCommentSuccess?.(newCommentData); @@ -161,6 +140,9 @@ export const Toolbar = ({ setShowAuthOverlay(true); return; } + const videoElement = document.querySelector( + "video", + ) as HTMLVideoElement; if (videoElement) { videoElement.pause(); } @@ -172,13 +154,14 @@ export const Toolbar = ({ return () => { window.removeEventListener("keydown", handleKeyPress); }; - }, [commentBoxOpen, user, videoElement]); + }, [commentBoxOpen, user]); const handleCommentClick = () => { if (!user) { setShowAuthOverlay(true); return; } + const videoElement = document.querySelector("video") as HTMLVideoElement; if (videoElement) { videoElement.pause(); } @@ -233,9 +216,7 @@ export const Toolbar = ({ handleCommentSubmit(); }} > - {videoElement && getTimestamp() > 0 - ? `Comment at ${getTimestamp().toFixed(2)}` - : "Comment"} + Comment -
-

+

+

{comment.authorName || "Anonymous"}

- -

- {formatTimeAgo(commentDate)} -

-
- {comment.timestamp && ( - - )} +
+ +

+ {formatTimeAgo(commentDate)} +

+
+ {comment.timestamp !== null && ( + + )} +
-

{comment.content}

+

{comment.content}

{user && !isReplying && canReply && ( diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx index 92337c8911..03153a3b30 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Comments.tsx @@ -7,6 +7,8 @@ import { type ComponentProps, forwardRef, type PropsWithChildren, + startTransition, + useCallback, useEffect, useImperativeHandle, useRef, @@ -38,6 +40,7 @@ export const Comments = Object.assign( setOptimisticComments, setComments, handleCommentSuccess, + onSeek, } = props; const commentParams = useSearchParams().get("comment"); const replyParams = useSearchParams().get("reply"); @@ -53,24 +56,28 @@ export const Comments = Object.assign( commentsContainerRef.current.scrollTop = commentsContainerRef.current.scrollHeight; } - }, []); + }, [commentParams, replyParams]); - const scrollToBottom = () => { + const scrollToBottom = useCallback(() => { if (commentsContainerRef.current) { commentsContainerRef.current.scrollTo({ top: commentsContainerRef.current.scrollHeight, behavior: "smooth", }); } - }; + }, []); - useImperativeHandle(ref, () => ({ scrollToBottom }), []); + useImperativeHandle(ref, () => ({ scrollToBottom }), [scrollToBottom]); const rootComments = optimisticComments.filter( (comment) => !comment.parentCommentId || comment.parentCommentId === "", ); const handleNewComment = async (content: string) => { + // Get current video time from the video element + const videoElement = document.querySelector("video") as HTMLVideoElement; + const currentTime = videoElement?.currentTime || 0; + const optimisticComment: CommentType = { id: `temp-${Date.now()}`, authorId: user?.id || "anonymous", @@ -80,12 +87,14 @@ export const Comments = Object.assign( videoId: props.videoId, parentCommentId: "", type: "text", - timestamp: null, + timestamp: currentTime, updatedAt: new Date(), sending: true, }; - setOptimisticComments(optimisticComment); + startTransition(() => { + setOptimisticComments(optimisticComment); + }); try { const data = await newComment({ @@ -93,6 +102,7 @@ export const Comments = Object.assign( videoId: props.videoId, parentCommentId: "", type: "text", + timestamp: currentTime, }); handleCommentSuccess(data); } catch (error) { @@ -102,6 +112,8 @@ export const Comments = Object.assign( const handleReply = async (content: string) => { if (!replyingTo) return; + const videoElement = document.querySelector("video") as HTMLVideoElement; + const currentTime = videoElement?.currentTime || 0; const parentComment = optimisticComments.find((c) => c.id === replyingTo); const actualParentId = parentComment?.parentCommentId @@ -117,12 +129,14 @@ export const Comments = Object.assign( videoId: props.videoId, parentCommentId: actualParentId, type: "text", - timestamp: null, + timestamp: currentTime, updatedAt: new Date(), sending: true, }; - setOptimisticComments(optimisticReply); + startTransition(() => { + setOptimisticComments(optimisticReply); + }); try { const data = await newComment({ @@ -130,6 +144,7 @@ export const Comments = Object.assign( videoId: props.videoId, parentCommentId: actualParentId, type: "text", + timestamp: currentTime, }); handleCommentSuccess(data); @@ -195,7 +210,7 @@ export const Comments = Object.assign( onCancelReply={handleCancelReply} onDelete={handleDeleteComment} user={user} - onSeek={props.onSeek} + onSeek={onSeek} /> ))}
diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx index bbc8f3671c..ef7c6c3d57 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx @@ -3,7 +3,13 @@ import type { userSelectProps } from "@cap/database/auth/session"; import type { Video } from "@cap/web-domain"; import type React from "react"; -import { forwardRef, type JSX, Suspense, useState } from "react"; +import { + forwardRef, + type JSX, + type RefObject, + Suspense, + useState, +} from "react"; import { CapCardAnalytics } from "@/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics"; import type { CommentType } from "../../../Share"; import { AuthOverlay } from "../../AuthOverlay"; @@ -64,6 +70,7 @@ export const Activity = Object.assign( user={user} videoId={videoId} setShowAuthOverlay={setShowAuthOverlay} + onSeek={props.onSeek} /> )} diff --git a/apps/web/app/s/[videoId]/_components/video/media-player.tsx b/apps/web/app/s/[videoId]/_components/video/media-player.tsx index 2c04228774..d793e34311 100644 --- a/apps/web/app/s/[videoId]/_components/video/media-player.tsx +++ b/apps/web/app/s/[videoId]/_components/video/media-player.tsx @@ -46,7 +46,7 @@ import { useMediaSelector, } from "media-chrome/react/media-store"; import * as React from "react"; -import { forwardRef } from "react"; +import { forwardRef, useCallback, useEffect } from "react"; import * as ReactDOM from "react-dom"; import { useComposedRefs } from "@/app/lib/compose-refs"; import { cn } from "@/app/lib/utils"; @@ -877,16 +877,29 @@ function MediaPlayerAudio(props: MediaPlayerAudioProps) { interface MediaPlayerControlsProps extends React.ComponentProps<"div"> { asChild?: boolean; isUploadingOrFailed?: boolean; + mainControlsVisible?: (arg: boolean) => void; } function MediaPlayerControls(props: MediaPlayerControlsProps) { - const { asChild, className, isUploadingOrFailed, ...controlsProps } = props; + const { + asChild, + className, + isUploadingOrFailed, + mainControlsVisible, + ...controlsProps + } = props; const context = useMediaPlayerContext("MediaPlayerControls"); const isFullscreen = useMediaSelector( (state) => state.mediaIsFullscreen ?? false, ); const controlsVisible = useStoreSelector((state) => state.controlsVisible); + // Call the callback whenever controlsVisible changes + useEffect(() => { + if (typeof mainControlsVisible === "function") { + mainControlsVisible(controlsVisible); + } + }, [mainControlsVisible, controlsVisible]); const ControlsPrimitive = asChild ? Slot : "div"; diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 63a7904dfe..3810fdc2cb 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -317,7 +317,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { Effect.succeed({ needsPassword: true } as const), ), Effect.map((data) => ( -
+
{!data.needsPassword && ( @@ -327,7 +327,10 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { Effect.catchTags({ PolicyDenied: () => Effect.succeed( -
+

This video is private @@ -368,7 +371,8 @@ async function AuthorizedContent({ authorId: user.id, }); } catch (error) { - console.error("Failed to create view notification:", error); + console.warn("Failed to create view notification:", error); + // Don't throw the error, just log it since this is not critical for the page to function } } diff --git a/apps/web/lib/Notification.ts b/apps/web/lib/Notification.ts index 626d0a1797..62a1415721 100644 --- a/apps/web/lib/Notification.ts +++ b/apps/web/lib/Notification.ts @@ -32,23 +32,49 @@ export async function createNotification( notification: CreateNotificationInput, ) { try { - // First, get the video and owner data - const [videoResult] = await db() + // First, check if the video exists + const [videoExists] = await db() + .select({ id: videos.id, ownerId: videos.ownerId }) + .from(videos) + .where(eq(videos.id, Video.VideoId.make(notification.videoId))) + .limit(1); + + if (!videoExists) { + console.error("Video not found for videoId:", notification.videoId); + throw new Error(`Video not found for videoId: ${notification.videoId}`); + } + + // Then get the owner data + const [ownerResult] = await db() .select({ - videoId: videos.id, - ownerId: users.id, + id: users.id, activeOrganizationId: users.activeOrganizationId, preferences: users.preferences, }) - .from(videos) - .innerJoin(users, eq(users.id, videos.ownerId)) - .where(eq(videos.id, Video.VideoId.make(notification.videoId))) + .from(users) + .where(eq(users.id, videoExists.ownerId)) .limit(1); - if (!videoResult) { - throw new Error("Video or owner not found"); + if (!ownerResult) { + console.warn( + "Owner not found for videoId:", + notification.videoId, + "ownerId:", + videoExists.ownerId, + "- skipping notification creation", + ); + // Don't throw an error, just skip notification creation + // This handles cases where the video exists but the owner was deleted + return; } + const videoResult = { + videoId: videoExists.id, + ownerId: ownerResult.id, + activeOrganizationId: ownerResult.activeOrganizationId, + preferences: ownerResult.preferences, + }; + const { type, ...data } = notification; // Handle replies: notify the parent comment's author