1371 words
7 minutes
Video Annotation Tool
2025-04-11
No Tags

Building a Video Annotation Tool: From YouTube API to Redux State Management#

Introduction#

Ever tried to take notes while watching a video tutorial? You pause, write the timestamp, type your note, resume playback—it’s tedious. I wanted a better solution, so I built a video annotation tool that automatically captures timestamps and lets you jump to any moment with a single click.

In this post, I’ll walk you through building a production-ready video annotation application using Vite, React, and Redux. More importantly, I’ll share the architectural decisions, the challenges I faced, and why certain patterns made all the difference.

Building a Video Annotation Tool: From YouTube API to Redux State Management#

Introduction#

In this post, I’ll walk you through building a production-ready video annotation application using Vite, React, and Redux. More importantly, I’ll share the architectural decisions, the challenges I faced, and why certain patterns made all the difference.

The Vision#

What I wanted to build:

  • Annotate both YouTube videos and uploaded files
  • Automatic timestamp capture
  • Click-to-seek functionality
  • Real-time synchronization between video and notes
  • Clean, intuitive interface
  • Exportable annotations

Why this project matters: Video annotation is used in education, content creation, research, and accessibility. Understanding how to build it teaches you state management, external API integration, file handling, and real-time UI updates—skills that transfer to countless other applications.

Tech Stack: Why These Choices?#

Redux Toolkit Over useState#

With annotations, video time, playback state, and multiple video sources, state management could spiral out of control. Redux Toolkit provided:

Predictable State Flow:

User clicks "Add Annotation"
Dispatch action
Reducer updates state
Components re-render
UI updates

Time Travel Debugging: Redux DevTools let me scrub through state changes. When annotations weren’t syncing correctly, I could see exactly when and why state updated.

Single Source of Truth: Video time, annotations, and player state all live in one store. No prop drilling. No state inconsistencies.

YouTube IFrame API Integration#

The YouTube IFrame API was both powerful and frustrating. Here’s what I learned:

The Good:

  • Programmatic playback control
  • Event listeners for player state
  • Quality and speed control
  • Thumbnail generation

The Challenging:

  • Asynchronous initialization
  • Cross-origin restrictions
  • API loading race conditions
  • Documentation gaps

Architecture: The Foundation#

Redux Slice Design#

I structured state around the annotation workflow:

const initialState = {
// Video source management
videoSource: {
type: null, // 'youtube' | 'upload'
url: null, // YouTube URL
file: null // Uploaded file
},
// Annotation data
annotations: [], // Array of { id, timestamp, text }
// Playback state
currentTime: 0, // Current video position
isPlaying: false, // Is video playing?
// UI state
selectedAnnotation: null, // Currently selected annotation
isEditMode: false, // Is user editing?
playerReady: false // Is player initialized?
};

Why this structure?

  1. Separation of concerns: Video source separate from annotations separate from playback
  2. Extensibility: Easy to add new video sources (Vimeo, local streaming)
  3. UI state isolation: Edit mode doesn’t affect video playback

Component Hierarchy#

App
├── Header
├── VideoSourceSelector
│ ├── YouTube URL Input
│ └── File Upload Button
├── VideoPlayer
│ ├── YouTube IFrame
│ └── HTML5 Video
├── AnnotationsPanel
│ ├── Annotation List
│ │ └── Annotation Item
│ │ ├── Timestamp
│ │ ├── Text
│ │ └── Actions (Edit/Delete)
│ └── Add Button
└── AddAnnotationModal
├── Timestamp Display
├── Text Input
└── Save/Cancel

Each component has a single responsibility. Want to change how annotations render? Edit AnnotationsPanel. Want to add Vimeo support? Modify VideoPlayer. Clean, maintainable architecture.

YouTube API Integration: The Details#

Loading the API#

The YouTube IFrame API must be loaded before use. I created a utility function:

const loadYouTubeAPI = () => {
return new Promise((resolve) => {
if (window.YT && window.YT.Player) {
resolve();
return;
}
window.onYouTubeIframeAPIReady = () => {
resolve();
};
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
});
};

Why a Promise?

The API loads asynchronously. Trying to create a player before it’s ready causes cryptic errors. Wrapping in a Promise lets us await readiness.

Creating the Player#

useEffect(() => {
const initPlayer = async () => {
await loadYouTubeAPI();
const player = new window.YT.Player('youtube-player', {
videoId: extractVideoId(videoUrl),
events: {
onReady: handlePlayerReady,
onStateChange: handleStateChange
}
});
playerRef.current = player;
};
if (videoSource.type === 'youtube' && videoSource.url) {
initPlayer();
}
}, [videoSource]);

Critical Details:

  1. playerRef: Stored in a ref, not state. Player instance doesn’t need to trigger re-renders.
  2. Conditional initialization: Only create player when YouTube URL exists
  3. Event handlers: Critical for time synchronization

Time Synchronization#

The hardest part was keeping Redux state in sync with video time:

useEffect(() => {
if (!isPlaying) return;
const interval = setInterval(() => {
if (playerRef.current?.getCurrentTime) {
const time = playerRef.current.getCurrentTime();
dispatch(updateCurrentTime(time));
}
}, 100);
return () => clearInterval(interval);
}, [isPlaying]);

Why 100ms intervals?

  • Frequent enough for smooth UI updates
  • Not so frequent it causes performance issues
  • Matches standard video frame rates

I tried 16ms (60fps) initially. It caused Redux to choke on update volume. 100ms was the sweet spot.

File Upload: Local Video Support#

Handling File Input#

const handleFileUpload = (event) => {
const file = event.target.files[0];
if (!file.type.startsWith('video/')) {
alert('Please upload a valid video file');
return;
}
dispatch(setVideoSource({
type: 'upload',
file: file
}));
};

Creating Object URLs#

To display uploaded videos, I created object URLs:

useEffect(() => {
if (videoSource.type !== 'upload') return;
const url = URL.createObjectURL(videoSource.file);
videoRef.current.src = url;
return () => URL.revokeObjectURL(url);
}, [videoSource]);

Memory Management:

Object URLs create memory leaks if not revoked. The cleanup function in useEffect prevents this.

HTML5 Video API#

For uploaded files, I used the standard HTML5 video element:

<video
ref={videoRef}
controls
onTimeUpdate={(e) => dispatch(updateCurrentTime(e.target.currentTime))}
onPlay={() => dispatch(setPlaying(true))}
onPause={() => dispatch(setPlaying(false))}
/>

Annotation Management: CRUD Operations#

Creating Annotations#

const handleAddAnnotation = () => {
const annotation = {
id: crypto.randomUUID(),
timestamp: currentTime,
text: annotationText,
createdAt: Date.now()
};
dispatch(addAnnotation(annotation));
dispatch(closeModal());
};

Automatic timestamp capture was crucial. When users click “Add,” the current video time is automatically saved. No manual input needed.

Editing Annotations#

const handleEditAnnotation = (id, newText) => {
dispatch(updateAnnotation({
id,
text: newText
}));
};

Design decision: Timestamps are immutable. You can edit text but not the timestamp. Why? Changing timestamps would break the annotation’s context in the video.

Deleting Annotations#

const handleDeleteAnnotation = (id) => {
if (confirm('Delete this annotation?')) {
dispatch(deleteAnnotation(id));
}
};

Simple confirm dialog prevents accidental deletions.

Click-to-Seek#

The most satisfying feature to implement:

const handleAnnotationClick = (timestamp) => {
if (playerRef.current?.seekTo) {
playerRef.current.seekTo(timestamp);
} else if (videoRef.current) {
videoRef.current.currentTime = timestamp;
}
dispatch(setSelectedAnnotation(id));
};

Unified interface: Same click handler works for both YouTube and uploaded videos. Different APIs, same user experience.

Real-Time UI Updates#

Highlighting Active Annotation#

As the video plays, the current annotation highlights:

const isActive = (annotation) => {
return currentTime >= annotation.timestamp &&
(nextAnnotation ? currentTime < nextAnnotation.timestamp : true);
};
return (
<div className={cn(
'annotation',
isActive(annotation) && 'active'
)}>
{/* Annotation content */}
</div>
);

Visual feedback is critical. Users need to see which annotation corresponds to what they’re currently watching.

Smooth Scrolling#

When an annotation becomes active, scroll it into view:

useEffect(() => {
const activeElement = document.querySelector('.annotation.active');
if (activeElement) {
activeElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
}, [currentTime]);

Prevents users from losing their place in long annotation lists.

Copy to Clipboard Feature#

Users wanted to export annotations. I added a “Copy All” button:

const copyToClipboard = () => {
const text = annotations
.map(a => `[${formatTimestamp(a.timestamp)}] ${a.text}`)
.join('\n');
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard!');
};
const formatTimestamp = (seconds) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};

Format: [0:01:23] This is my annotation text

Clean, readable, easy to paste into notes.

Performance Optimization#

Memoization#

Annotation list re-rendered on every time update. Fixed with React.memo:

const AnnotationItem = React.memo(({ annotation, isActive, onClick }) => {
return (
<div className={isActive ? 'active' : ''} onClick={onClick}>
{annotation.text}
</div>
);
}, (prevProps, nextProps) => {
return prevProps.isActive === nextProps.isActive &&
prevProps.annotation.id === nextProps.annotation.id;
});

Reduced re-renders by 90%.

What I’d Do Differently#

1. Add TypeScript#

JavaScript was fine for prototyping, but TypeScript would’ve caught several bugs earlier. Type-safe Redux actions would’ve been especially helpful.

3. Add Annotation Categories#

Let users tag annotations (question, important, note). Makes searching easier.

4. Persist to Local Storage#

Annotations disappear on page refresh. Should’ve added localStorage persistence or a backend.

Key Learnings#

  1. Redux Isn’t Overkill: For complex state, Redux simplifies everything. The boilerplate pays off.
  2. External APIs Need Error Handling: YouTube API can fail. Always have fallbacks.
  3. Performance Matters: 100ms timer updates vs 16ms makes huge difference.
  4. User Feedback Is Essential: Loading states, success messages, error handling—users need to know what’s happening.

Conclusion#

Building this video annotation tool made me spend time designing the Redux store structure upfront. That investment paid off as features grew. Adding new capabilities never required refactoring the core.

If you’re building anything with video, time-based data, or complex state, these patterns will serve you well.