Skip to main content

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} />;
}
videoId
Id<'videos'>
required
The video being watched
currentTime
number
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>
  );
}
videoId
Id<'videos'>
required
The video to list viewers for
viewers
array
Array of viewer presence objects
userId
string
Clerk user ID or share grant token
userName
string
User’s display name
userAvatarUrl
string | undefined
User’s avatar URL
currentTime
number | undefined
Last reported playback position in seconds
lastSeen
number
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 />;
}
videoId
Id<'videos'>
required
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>
  );
}
projectId
Id<'projects'>
required
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

Build docs developers (and LLMs) love