Skip to content
4 changes: 3 additions & 1 deletion apps/web/actions/videos/new-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export async function newComment(data: {
videoId: Video.VideoId;
type: "text" | "emoji";
parentCommentId: string;
timestamp: number;
}) {
const user = await getCurrentUser();

Expand All @@ -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"
Expand All @@ -41,7 +43,7 @@ export async function newComment(data: {
type: type,
content: content,
videoId: videoId,
timestamp: null,
timestamp: timestamp || null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: 0-second timestamps are coerced to null

Using timestamp || null drops valid 0 into null. Use nullish coalescing.

Apply this diff:

-		timestamp: timestamp || null,
+		timestamp: timestamp ?? null,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
timestamp: timestamp || null,
timestamp: timestamp ?? null,
🤖 Prompt for AI Agents
In apps/web/actions/videos/new-comment.ts around line 46, the assignment
`timestamp: timestamp || null` incorrectly converts a valid 0 timestamp to null;
replace the logical OR with nullish coalescing so that only undefined or null
become null (i.e., use timestamp ?? null) and ensure the updated expression
preserves falsy numeric 0 values.

parentCommentId: parentCommentId,
createdAt: new Date(),
updatedAt: new Date(),
Expand Down
22 changes: 8 additions & 14 deletions apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
Expand Down Expand Up @@ -179,33 +182,24 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => {
{!sidebarCollapsed && (
<Link
href={
activeOrg?.organization.customDomain
isDomainSetupVerified
? `https://${activeOrg.organization.customDomain}`
: "/dashboard/settings/organization"
}
rel={
activeOrg?.organization.customDomain
isDomainSetupVerified
? "noopener noreferrer"
: undefined
}
target={
activeOrg?.organization.customDomain
? "_blank"
: "_self"
}
target={isDomainSetupVerified ? "_blank" : "_self"}
className="flex truncate w-full overflow-hidden flex-1 gap-1.5 items-center self-start"
>
<FontAwesomeIcon
icon={
activeOrg?.organization.customDomain
? faLink
: faCircleInfo
}
icon={isDomainSetupVerified ? faLink : faCircleInfo}
className="duration-200 size-3 text-gray-10"
/>
<p className="w-full text-[11px] flex-1 duration-200 truncate leading-0 text-gray-11">
{activeOrg?.organization.customDomain &&
activeOrg?.organization.domainVerified
{isDomainSetupVerified
? activeOrg?.organization.customDomain
: "No custom domain set"}
</p>
Expand Down
59 changes: 54 additions & 5 deletions apps/web/app/s/[videoId]/Share.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
94 changes: 86 additions & 8 deletions apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -51,9 +60,12 @@ export function CapVideoPlayer({
autoplay = false,
enableCrossOrigin = false,
hasActiveUpload,
comments = [],
onSeek,
}: Props) {
const [currentCue, setCurrentCue] = useState<string>("");
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);
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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<string | null>(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;
Expand Down Expand Up @@ -484,13 +533,15 @@ export function CapVideoPlayer({
playsInline
autoPlay={autoplay}
>
<track default kind="chapters" src={chaptersSrc} />
<track
label="English"
kind="captions"
srcLang="en"
src={captionsSrc}
/>
{chaptersSrc && <track default kind="chapters" src={chaptersSrc} />}
{captionsSrc && (
<track
label="English"
kind="captions"
srcLang="en"
src={captionsSrc}
/>
)}
</MediaPlayerVideo>
)}
<AnimatePresence>
Expand Down Expand Up @@ -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",
)}
>
Expand All @@ -551,8 +602,35 @@ export function CapVideoPlayer({
<MediaPlayerError />
)}
<MediaPlayerVolumeIndicator />

{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 (
<CommentStamp
key={comment.id}
comment={comment}
adjustedPosition={adjustedPosition}
handleMouseEnter={handleMouseEnter}
handleMouseLeave={handleMouseLeave}
onSeek={onSeek}
hoveredComment={hoveredComment}
/>
);
})}

<MediaPlayerControls
className="flex-col items-start gap-2.5"
mainControlsVisible={(arg: boolean) => setMainControlsVisible(arg)}
isUploadingOrFailed={isUploading || isUploadFailed}
>
<MediaPlayerControlsOverlay />
Expand Down
85 changes: 85 additions & 0 deletions apps/web/app/s/[videoId]/_components/CommentStamp.tsx
Original file line number Diff line number Diff line change
@@ -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<CommentStampsProps> = ({
comment,
adjustedPosition,
handleMouseEnter,
handleMouseLeave,
onSeek,
hoveredComment,
}: CommentStampsProps) => {
return (
<div
key={comment.id}
className="absolute z-[50]"
style={{
left: adjustedPosition,
transform: "translateX(-50%)",
bottom: "65px",
}}
onMouseEnter={() => handleMouseEnter(comment.id)}
onMouseLeave={handleMouseLeave}
>
{/* Comment marker */}
<button
type="button"
onClick={() => {
if (onSeek && comment.timestamp !== null) {
onSeek(Number(comment.timestamp));
}
}}
className="flex justify-center items-center bg-black rounded-full transition-all cursor-pointer size-6 hover:opacity-75"
>
{comment.type === "emoji" ? (
<span className="text-sm">{comment.content}</span>
) : (
<FontAwesomeIcon icon={faComment} className="text-white size-3" />
)}
</button>

{hoveredComment === comment.id && (
<div className="absolute z-[50] bottom-full left-1/2 transform -translate-x-1/2 mb-2 bg-black backdrop-blur-md rounded-lg px-3 py-2 shadow-lg min-w-[200px] max-w-[300px]">
{/* Arrow pointing down to marker */}
<div className="absolute top-full left-1/2 w-0 h-0 border-t-4 border-r-4 border-l-4 border-black transform -translate-x-1/2 border-l-transparent border-r-transparent"></div>

<div className="flex gap-2 items-center">
{/* User avatar/initial */}
<Avatar
className="size-6"
letterClass="text-sm"
name={comment.authorName}
/>
{/* Comment content */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white truncate">
{comment.authorName || "Anonymous"}
</div>
<div className="text-xs truncate text-gray-11">
{comment.content}
</div>
</div>
</div>
</div>
)}
</div>
);
};

export default CommentStamp;
Loading
Loading