Overview
Video Presence tracks which users are currently watching videos, enabling real-time collaboration features like “who’s viewing” indicators and live cursors. Built on the Convex presence system.
All presence functions are defined in convex/videoPresence.ts.
Functions
heartbeat
Sends a presence heartbeat to indicate a user is actively viewing a video.
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
import { useEffect } from 'react';
function VideoPlayer({ videoId }) {
const heartbeat = useMutation(api.videoPresence.heartbeat);
useEffect(() => {
// Send heartbeat every 5 seconds
const interval = setInterval(() => {
heartbeat({
videoId,
currentTime: playerRef.current?.currentTime ?? 0,
});
}, 5000);
return () => clearInterval(interval);
}, [videoId]);
return <video ref={playerRef} />;
}
Current playback position in seconds (optional)
Permissions: Requires access to the video (viewer role or share grant)
Implementation: convex/videoPresence.ts:12
Heartbeats automatically expire after 30 seconds of inactivity. Send heartbeats regularly to maintain presence.
list
Lists all users currently viewing a specific video.
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function VideoViewers({ videoId }) {
const viewers = useQuery(api.videoPresence.list, { videoId });
return (
<div className="viewers">
<h4>Currently watching ({viewers?.length ?? 0})</h4>
{viewers?.map(viewer => (
<div key={viewer.userId} className="viewer">
<img src={viewer.userAvatarUrl} alt={viewer.userName} />
<span>{viewer.userName}</span>
{viewer.currentTime && (
<span className="timestamp">
at {formatTime(viewer.currentTime)}
</span>
)}
</div>
))}
</div>
);
}
The video to list viewers for
Array of viewer presence objectsClerk user ID or share grant token
Last reported playback position in seconds
Timestamp of last heartbeat
Permissions: Requires access to the video (viewer role or share grant)
Implementation: convex/videoPresence.ts:35
disconnect
Explicitly removes a user’s presence (called on unmount or navigation away).
import { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
import { useEffect } from 'react';
function VideoPlayer({ videoId }) {
const disconnect = useMutation(api.videoPresence.disconnect);
useEffect(() => {
return () => {
// Cleanup presence on unmount
disconnect({ videoId });
};
}, [videoId]);
return <video />;
}
The video to disconnect from
Permissions: Requires access to the video
Implementation: convex/videoPresence.ts:68
While presence automatically expires, explicitly calling disconnect provides a better user experience by immediately removing the user from the viewers list.
listProjectOnlineCounts
Gets the count of active viewers for all videos in a project.
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function ProjectVideos({ projectId }) {
const videos = useQuery(api.videos.list, { projectId });
const onlineCounts = useQuery(api.videoPresence.listProjectOnlineCounts, {
projectId
});
return (
<div>
{videos?.map(video => (
<div key={video._id}>
<h3>{video.title}</h3>
{onlineCounts?.[video._id] && (
<span className="badge">
👁️ {onlineCounts[video._id]} watching
</span>
)}
</div>
))}
</div>
);
}
The project to get viewer counts for
counts
Record<Id<'videos'>, number>
Map of video IDs to active viewer counts
Permissions: Requires viewer role in the project’s team
Implementation: convex/videoPresence.ts:88
Usage Examples
Complete Presence Integration
import { useQuery, useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
import { useEffect, useRef } from 'react';
function CollaborativeVideoPlayer({ videoId }) {
const playerRef = useRef(null);
const viewers = useQuery(api.videoPresence.list, { videoId });
const heartbeat = useMutation(api.videoPresence.heartbeat);
const disconnect = useMutation(api.videoPresence.disconnect);
// Send heartbeats
useEffect(() => {
const interval = setInterval(() => {
const currentTime = playerRef.current?.currentTime ?? 0;
heartbeat({ videoId, currentTime });
}, 5000);
return () => {
clearInterval(interval);
disconnect({ videoId });
};
}, [videoId]);
return (
<div className="video-container">
<div className="viewers-bar">
<div className="viewer-count">
{viewers?.length ?? 0} watching
</div>
<div className="viewer-list">
{viewers?.map(viewer => (
<div key={viewer.userId} className="viewer-avatar" title={viewer.userName}>
<img src={viewer.userAvatarUrl} alt={viewer.userName} />
{viewer.currentTime !== undefined && (
<span className="viewer-time">{formatTime(viewer.currentTime)}</span>
)}
</div>
))}
</div>
</div>
<video ref={playerRef} src={videoUrl} controls />
</div>
);
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
Project-Wide Activity Dashboard
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function ProjectDashboard({ projectId }) {
const videos = useQuery(api.videos.list, { projectId });
const onlineCounts = useQuery(api.videoPresence.listProjectOnlineCounts, {
projectId
});
const totalViewers = Object.values(onlineCounts ?? {}).reduce((a, b) => a + b, 0);
return (
<div>
<div className="stats-bar">
<div className="stat">
<strong>{videos?.length ?? 0}</strong> videos
</div>
<div className="stat">
<strong>{totalViewers}</strong> active viewers
</div>
</div>
<div className="video-grid">
{videos?.map(video => {
const viewerCount = onlineCounts?.[video._id] ?? 0;
return (
<div key={video._id} className="video-card">
<h3>{video.title}</h3>
{viewerCount > 0 && (
<div className="live-indicator">
<span className="pulse" />
{viewerCount} watching now
</div>
)}
</div>
);
})}
</div>
</div>
);
}
Live Playback Position Sync
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function SyncedViewers({ videoId, currentUserId }) {
const viewers = useQuery(api.videoPresence.list, { videoId });
// Filter out current user
const otherViewers = viewers?.filter(v => v.userId !== currentUserId) ?? [];
return (
<div className="timeline-markers">
{otherViewers.map(viewer => {
if (viewer.currentTime === undefined) return null;
const position = (viewer.currentTime / videoDuration) * 100;
return (
<div
key={viewer.userId}
className="viewer-marker"
style={{ left: `${position}%` }}
title={`${viewer.userName} at ${formatTime(viewer.currentTime)}`}
>
<img src={viewer.userAvatarUrl} alt={viewer.userName} />
</div>
);
})}
</div>
);
}
Technical Details
Presence Expiration
- Presence entries automatically expire after 30 seconds without a heartbeat
- Recommended heartbeat interval: 5 seconds
- Always call
disconnect() on component unmount for immediate cleanup
Performance Considerations
- Presence queries are optimized with database indexes
- Heartbeats are lightweight mutations that complete quickly
- Project-wide counts are aggregated efficiently
- Consider debouncing heartbeat calls during rapid player scrubbing
Privacy
- Presence respects video access permissions
- Users can only see presence for videos they have access to
- Share grant users appear with their grant token as their userId