High-Level Design: Video Streaming Platform (YouTube-like)
Table of Contents
- Problem Statement & Requirements
- High-Level Architecture
- Component Architecture
- Data Flow
- API Design & Communication Protocols
- Database Design
- Caching Strategy
- State Management
- Performance Optimization
- Error Handling & Edge Cases
- Interview Cross-Questions
- Trade-offs & Design Decisions
- Accessibility (a11y)
- Security & Content Protection
- Mobile & Touch Interactions
- Testing Strategy
- Offline/PWA Capabilities
- Video Player Deep Dive
- Internationalization (i18n)
- Analytics & Monitoring
- Notification System
- Live Streaming Deep Dive
1. Problem Statement & Requirements
Functional Requirements
- Video Upload: Users can upload videos in various formats and sizes
- Video Streaming: Users can watch videos with adaptive quality
- Video Player Controls: Play, pause, seek, volume, quality selection, speed control
- Comments: Users can post, read, edit, and delete comments
- Recommendations: Personalized video suggestions based on viewing history
- Search: Search videos by title, tags, description
- Thumbnails: Auto-generate and custom upload thumbnails
- View Counting: Track video views accurately (eventual consistency acceptable)
- Live Streaming: Support for real-time video streaming (optional)
- Subscriptions: Users can subscribe to channels
- Likes/Dislikes: User engagement metrics
Non-Functional Requirements
- Scalability: Support millions of concurrent viewers
- Availability: 99.9% uptime for streaming service
- Low Latency: Video start time < 2 seconds, buffering minimal
- Consistency: Eventual consistency acceptable for views, likes
- Durability: Videos should never be lost (high durability)
- Performance: Support adaptive bitrate streaming (240p to 4K)
- Cost Efficiency: Optimize storage and bandwidth costs
- Global Reach: CDN-based delivery for worldwide users
Scale Estimations
- Users: 500M daily active users
- Videos: 500 hours of video uploaded per minute
- Concurrent Viewers: 10M concurrent video streams
- Storage: 1PB+ for video storage (multi-resolution)
- Bandwidth: 100+ Gbps for video delivery
- Metadata: Billions of video records, comments, likes
2. High-Level Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ CLIENT LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Web App │ │ Mobile App │ │ Smart TV │ │
│ │ (React/Vue) │ │ (iOS/Android)│ │ App │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
└─────────┼──────────────────┼──────────────────┼──────────────────────────┘
│ │ │
└──────────────────┼──────────────────┘
│
┌────────▼────────┐
│ API Gateway │
│ (Rate Limiting,│
│ Auth, Routing)│
└────────┬────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌──────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐
│ Video │ │ Metadata │ │ User │
│ Upload │ │ Service │ │ Service │
│ Service │ │ │ │ │
└──────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
│ │ │
┌──────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐
│ Transcode │ │ Comment │ │ Recommend. │
│ Service │ │ Service │ │ Service │
│ (Queue) │ │ │ │ (ML) │
└──────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
│ │ │
┌─────────▼─────────────────▼──────────────────▼─────────────┐
│ DATA LAYER │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ SQL │ │ NoSQL │ │ Object │ │ Cache │ │
│ │ (RDS) │ │(Cassandra│ │ Storage │ │ (Redis) │ │
│ │ │ │/DynamoDB)│ │ (S3) │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ CDN LAYER │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ CDN Edge│ │ CDN Edge│ │ CDN Edge│ │ CDN Edge│ │
│ │ (US) │ │ (EU) │ │ (APAC) │ │ (Others)│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ BACKGROUND JOBS │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Thumbnail │ │ View │ │Analytics │ │ CDN │ │
│ │Generator │ │ Counter │ │Processor │ │ Warmer │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
2.0.a System Invariants & Frontend Consistency Model
A video streaming frontend is a distributed system between:
- Browser
- CDN
- Player runtime
- Backend control plane
The UI must enforce correctness even when these components are temporarily inconsistent.
These invariants must never be violated.
2.0.b Playback State Invariants
| Invariant | Why it must hold |
|---|---|
| UI must never show “Playing” if no media bytes are being consumed | Prevents fake playback |
| UI must never advance timestamp if buffer is empty | Prevents desync |
| Video time must be monotonic except during user seeks | Prevents jumps |
| Pause must always stop network fetch | Prevents waste + billing errors |
| A stalled network must never lock UI | Prevents dead-UI |
These are enforced by the player state machine.
2.0.c Buffering Invariants
The buffer is the frontend’s source of truth for playback.
Rules:
- Playback can start only when buffer ≥ minimum threshold
- Playback must pause automatically when buffer = 0
- Video must never outrun buffer
- Buffer eviction must never remove currently playing segment
Violation of these rules causes:
- Infinite spinner bugs
- Ghost playback
- Desync between audio and video
2.0.d Manifest Consistency
The manifest is the contract between backend and player.
The frontend enforces:
| Rule | Why |
|---|---|
| Manifest must always match codec in buffer | Prevents decode failure |
| Manifest refresh must be idempotent | Prevents rewind bugs |
| Manifest updates must not invalidate already-buffered segments | Prevents playback drop |
Frontend always treats manifest as append-only during active playback.
2.0.e UI vs Network Truth
The UI does not trust the network.
The UI derives state from:
- Player buffer
- Decoder state
- Playback clock
Not from:
- CDN responses
- Backend playback metadata
This prevents:
- “Video says playing but nothing plays”
- Broken pause/play buttons
- Wrong seek bar
2.0.f Live Stream Invariants
For live video:
- The playback head must never go beyond live edge
- The UI must expose latency vs quality tradeoff
- Rebuffering must not rewind live edge
Live video correctness is harder than VOD.
The frontend enforces:
- Drift control
- Jitter smoothing
- Live-edge correction
5. Frontend Failure Model
The frontend assumes failure is normal.
5.1 CDN Failure
If a video segment fails to load:
- Retry same CDN
- Try alternate CDN
- Drop to lower bitrate
- Pause playback if buffer hits zero
The UI never shows “playing” unless buffer is moving.
5.2 Manifest Failure
If manifest refresh fails:
- Continue playing buffered segments
- Freeze bitrate adaptation
- Retry in background
Playback should not drop just because control plane failed.
5.3 Network Degradation
The frontend continuously measures:
- Download speed
- Buffer growth
- Rebuffer frequency
It dynamically:
- Reduces quality
- Increases buffer targets
- Disables prefetch
This keeps playback stable instead of pretty.
5.4 Tab Suspension / Mobile App Backgrounding
If browser or app is suspended:
- Freeze playback clock
- Save buffer state
- Resume without reloading
Avoids restarting video.
5.5 User Actions During Failure
Users may:
- Seek while buffering
- Pause during network loss
- Switch quality mid-segment
The frontend serializes these actions and applies them when safe.
No user input is lost.
3. Component Architecture
3.1 Frontend Components
┌────────────────────────────────────────────────────────┐
│ VIDEO PLAYER COMPONENT │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Video Rendering Canvas │ │
│ │ (HTML5 Video / WebRTC for live) │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Player Controls │ │
│ │ [Play/Pause] [Timeline] [Volume] [Quality] │ │
│ │ [Speed] [Fullscreen] [Captions] [Settings] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Adaptive Bitrate Logic │ │
│ │ - Monitor bandwidth & buffer health │ │
│ │ - Switch quality based on network │ │
│ │ - Preload next segments │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Buffer Management │ │
│ │ - Maintain 10-30s buffer ahead │ │
│ │ - Handle network interruptions │ │
│ │ - Smart preloading │ │
│ └───────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
│ RECOMMENDATION COMPONENT │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Video │ │ Video │ │ Video │ │
│ │ Thumbnail│ │ Thumbnail│ │ Thumbnail│ ... │
│ │ + Meta │ │ + Meta │ │ + Meta │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ - Personalized based on watch history │
│ - Trending videos │
│ - Category-based recommendations │
└────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
│ COMMENTS COMPONENT │
│ ┌──────────────────────────────────────────────┐ │
│ │ Comment Input (with mentions, emojis) │ │
│ └──────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Comment List (paginated/infinite scroll) │ │
│ │ - Top comments │ │
│ │ - Newest first │ │
│ │ - Threaded replies │ │
│ └──────────────────────────────────────────────┘ │
│ - Real-time updates (WebSocket/Polling) │
│ - Like/Dislike comments │
└────────────────────────────────────────────────────────┘
3.1.a Frontend Failure Model
The frontend assumes failure is normal.
3.1.b CDN Failure
If a video segment fails to load:
- Retry same CDN
- Try alternate CDN
- Drop to lower bitrate
- Pause playback if buffer hits zero
The UI never shows “playing” unless buffer is moving.
3.1.c Manifest Failure
If manifest refresh fails:
- Continue playing buffered segments
- Freeze bitrate adaptation
- Retry in background
Playback should not drop just because control plane failed.
3.1.d Network Degradation
The frontend continuously measures:
- Download speed
- Buffer growth
- Rebuffer frequency
It dynamically:
- Reduces quality
- Increases buffer targets
- Disables prefetch
This keeps playback stable instead of pretty.
3.1.e Tab Suspension / Mobile App Backgrounding
If browser or app is suspended:
- Freeze playback clock
- Save buffer state
- Resume without reloading
Avoids restarting video.
3.1.f User Actions During Failure
Users may:
- Seek while buffering
- Pause during network loss
- Switch quality mid-segment
The frontend serializes these actions and applies them when safe.
No user input is lost.
3.2 Backend Services
Video Upload Service
┌─────────────────────────────────────────┐
│ VIDEO UPLOAD SERVICE │
│ │
│ 1. Validate upload (format, size) │
│ 2. Generate unique video ID │
│ 3. Chunk upload to Object Storage │
│ 4. Create metadata entry │
│ 5. Trigger transcoding job │
│ 6. Generate placeholder thumbnail │
│ │
│ Technologies: │
│ - Multipart upload (chunks) │
│ - Resumable uploads │
│ - S3/GCS for raw video storage │
└─────────────────────────────────────────┘
Transcoding Service
┌─────────────────────────────────────────┐
│ TRANSCODING SERVICE │
│ │
│ Input: Raw video file │
│ │
│ Process: │
│ 1. Read from Object Storage │
│ 2. Transcode to multiple resolutions: │
│ - 4K (2160p) │
│ - 1080p │
│ - 720p │
│ - 480p │
│ - 360p │
│ - 240p │
│ 3. Encode with H.264/H.265/VP9 │
│ 4. Generate HLS/DASH manifests │
│ 5. Extract thumbnails (keyframes) │
│ 6. Store segments in Object Storage │
│ 7. Update metadata with URLs │
│ 8. Warm CDN cache │
│ │
│ Technologies: │
│ - FFmpeg/Elastic Transcoder │
│ - Worker queue (SQS/RabbitMQ) │
│ - Distributed workers (Auto-scaling) │
└─────────────────────────────────────────┘
Streaming Service
┌─────────────────────────────────────────┐
│ STREAMING SERVICE │
│ │
│ 1. Receive video request │
│ 2. Check user authentication │
│ 3. Fetch manifest file (HLS/DASH) │
│ 4. Serve via CDN │
│ 5. Track playback events │
│ 6. Log analytics │
│ │
│ Protocols: │
│ - HLS (HTTP Live Streaming) │
│ - DASH (Dynamic Adaptive Streaming) │
│ - WebRTC (for live streaming) │
└─────────────────────────────────────────┘
4. Data Flow
4.1 Video Upload Flow
┌─────────┐
│ User │
└────┬────┘
│ 1. Upload Video
▼
┌─────────────────┐
│ Upload Service │
└────┬────────────┘
│ 2. Chunk & Store Raw Video
▼
┌─────────────────┐
│ Object Storage │
│ (S3) │
└────┬────────────┘
│ 3. Trigger Transcode
▼
┌─────────────────┐
│ Message Queue │
│ (SQS/Kafka) │
└────┬────────────┘
│ 4. Pick Job
▼
┌─────────────────┐
│ Transcoder │
│ Workers │
└────┬────────────┘
│ 5. Generate Multiple Resolutions
├──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼
[4K.m3u8] [1080p.m3u8] [720p.m3u8] [360p.m3u8]
│ │ │ │
└──────────┴──────────┴──────────┘
│
▼
┌─────────────────┐
│ Object Storage │
│ (Segmented) │
└────┬────────────┘
│ 6. Update Metadata
▼
┌─────────────────┐
│ Database │
│ (video_id, │
│ resolutions) │
└────┬────────────┘
│ 7. Warm CDN
▼
┌─────────────────┐
│ CDN │
│ Edge Servers │
└─────────────────┘
4.2 Video Streaming Flow
┌─────────┐
│ User │
└────┬────┘
│ 1. Request Video
▼
┌─────────────────┐
│ API Gateway │
└────┬────────────┘
│ 2. Auth & Validate
▼
┌─────────────────┐
│ Metadata Service│
└────┬────────────┘
│ 3. Fetch Video Info
▼
┌─────────────────┐
│ Database │
└────┬────────────┘
│ 4. Return Manifest URL
▼
┌─────────────────┐
│ Client │
│ (Video Player) │
└────┬────────────┘
│ 5. Request Manifest (master.m3u8)
▼
┌─────────────────┐
│ CDN Edge │
└────┬────────────┘
│ 6. Serve Manifest
▼
┌─────────────────┐
│ Client │
│ (Parse Quality)│
└────┬────────────┘
│ 7. Request Segments (720p_001.ts, 720p_002.ts...)
▼
┌─────────────────┐
│ CDN Edge │
│ (Cache Hit) │
└────┬────────────┘
│ 8. Stream Video Segments
▼
┌─────────────────┐
│ Video Player │
│ (Buffer & Play)│
└─────────────────┘
4.3 Adaptive Bitrate Flow
Video Player Logic:
┌─────────────────────────────────────────┐
│ │
│ while (video playing): │
│ monitor_bandwidth() │
│ monitor_buffer_health() │
│ │
│ if bandwidth_high && buffer_healthy: │
│ switch_to_higher_quality() │
│ if bandwidth_low || buffer_starving: │
│ switch_to_lower_quality() │
│ │
│ preload_next_segments() │
│ update_playback_stats() │
│ │
└─────────────────────────────────────────┘
Quality Ladder:
4K (2160p) ─┐
1080p ─┤
720p ─┼─── Switch based on:
480p ─┤ - Network speed
360p ─┤ - Buffer status
240p ─┘ - Device capability
4.4 View Counting Flow
┌─────────┐
│ User │
│ Watches │
└────┬────┘
│ 1. Video Play Event (>30s watched)
▼
┌─────────────────┐
│ Analytics │
│ Service │
└────┬────────────┘
│ 2. Write to Stream
▼
┌─────────────────┐
│ Kafka/Kinesis │
│ (Event Log) │
└────┬────────────┘
│ 3. Aggregate Views
▼
┌─────────────────┐
│ Stream │
│ Processor │
│ (Flink/Spark) │
└────┬────────────┘
│ 4. Batch Update (every 5 min)
▼
┌─────────────────┐
│ Redis Counter │
│ (Fast Reads) │
└────┬────────────┘
│ 5. Periodic Flush
▼
┌─────────────────┐
│ Database │
│ (Persistent) │
└─────────────────┘
Note: Eventual consistency is acceptable
Views may be slightly delayed (5-10 min)
5. API Design & Communication Protocols
5.1 REST APIs
Video Metadata APIs
GET /api/v1/videos/{videoId}
Response:
{
"videoId": "abc123",
"title": "Sample Video",
"description": "...",
"duration": 600,
"views": 1000000,
"likes": 50000,
"dislikes": 1000,
"channelId": "channel123",
"uploadDate": "2025-01-15T10:00:00Z",
"thumbnailUrl": "https://cdn.example.com/thumbnails/abc123.jpg",
"manifestUrl": "https://cdn.example.com/videos/abc123/master.m3u8",
"resolutions": ["2160p", "1080p", "720p", "480p", "360p", "240p"]
}
POST /api/v1/videos/upload
Headers:
Authorization: Bearer <token>
Body (multipart):
file: <video_file>
title: "Video Title"
description: "..."
tags: ["tag1", "tag2"]
Response:
{
"videoId": "abc123",
"uploadStatus": "processing",
"uploadUrl": "https://upload.example.com/abc123"
}
GET /api/v1/videos/{videoId}/recommendations
Response:
{
"videos": [
{"videoId": "xyz789", "title": "...", "thumbnailUrl": "..."},
...
]
}
Comment APIs
POST /api/v1/videos/{videoId}/comments
Headers:
Authorization: Bearer <token>
Body:
{
"text": "Great video!",
"parentCommentId": null
}
Response:
{
"commentId": "comment123",
"userId": "user456",
"text": "Great video!",
"createdAt": "2025-01-15T10:00:00Z"
}
GET /api/v1/videos/{videoId}/comments?limit=20&offset=0&sort=top
Response:
{
"comments": [
{
"commentId": "comment123",
"userId": "user456",
"userName": "John Doe",
"text": "Great video!",
"likes": 100,
"replies": 5,
"createdAt": "2025-01-15T10:00:00Z"
},
...
],
"nextOffset": 20
}
User Engagement APIs
POST /api/v1/videos/{videoId}/like
POST /api/v1/videos/{videoId}/dislike
POST /api/v1/channels/{channelId}/subscribe
POST /api/v1/videos/{videoId}/watch
Body: { "timestamp": 120, "quality": "720p" }
5.2 Streaming Protocols
HLS (HTTP Live Streaming)
Master Playlist (master.m3u8):
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=3840x2160
2160p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720
720p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=854x480
480p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
360p/index.m3u8
Quality Playlist (720p/index.m3u8):
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXTINF:10.0,
segment_001.ts
#EXTINF:10.0,
segment_002.ts
#EXTINF:10.0,
segment_003.ts
...
#EXT-X-ENDLIST
DASH (Dynamic Adaptive Streaming over HTTP)
MPD (Media Presentation Description):
<?xml version="1.0"?>
<MPD>
<Period>
<AdaptationSet mimeType="video/mp4">
<Representation bandwidth="8000000" width="3840" height="2160">
<BaseURL>2160p/</BaseURL>
<SegmentTemplate />
</Representation>
<Representation bandwidth="5000000" width="1920" height="1080">
<BaseURL>1080p/</BaseURL>
<SegmentTemplate />
</Representation>
...
</AdaptationSet>
</Period>
</MPD>
5.3 WebSocket (Real-time Updates)
// Comment updates
WS /ws/videos/{videoId}/comments
Client -> Server:
{
"type": "subscribe",
"videoId": "abc123"
}
Server -> Client (new comment):
{
"type": "new_comment",
"comment": {
"commentId": "comment789",
"userId": "user123",
"text": "Just posted!",
"createdAt": "2025-01-15T10:05:00Z"
}
}
// Live view count updates
Server -> Client (every 10s):
{
"type": "view_count_update",
"views": 1000543
}
6. Database Design
6.1 SQL Database (MySQL/PostgreSQL) - Metadata
Videos Table
CREATE TABLE videos (
video_id VARCHAR(36) PRIMARY KEY,
channel_id VARCHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
duration INT NOT NULL, -- in seconds
upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status ENUM('processing', 'ready', 'failed') DEFAULT 'processing',
category_id INT,
privacy ENUM('public', 'unlisted', 'private') DEFAULT 'public',
manifest_url VARCHAR(512),
thumbnail_url VARCHAR(512),
raw_video_url VARCHAR(512),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_channel_id (channel_id),
INDEX idx_upload_date (upload_date),
INDEX idx_status (status),
INDEX idx_category (category_id)
);
CREATE TABLE video_resolutions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
video_id VARCHAR(36) NOT NULL,
resolution VARCHAR(10) NOT NULL, -- '720p', '1080p', etc.
video_url VARCHAR(512) NOT NULL,
bitrate INT,
file_size BIGINT,
FOREIGN KEY (video_id) REFERENCES videos(video_id),
INDEX idx_video_id (video_id)
);
CREATE TABLE video_stats (
video_id VARCHAR(36) PRIMARY KEY,
view_count BIGINT DEFAULT 0,
like_count INT DEFAULT 0,
dislike_count INT DEFAULT 0,
comment_count INT DEFAULT 0,
share_count INT DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (video_id) REFERENCES videos(video_id)
);
Channels Table
CREATE TABLE channels (
channel_id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
channel_name VARCHAR(100) NOT NULL,
description TEXT,
subscriber_count BIGINT DEFAULT 0,
total_views BIGINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id)
);
CREATE TABLE subscriptions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
channel_id VARCHAR(36) NOT NULL,
subscribed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
notifications_enabled BOOLEAN DEFAULT TRUE,
UNIQUE KEY unique_subscription (user_id, channel_id),
INDEX idx_user_id (user_id),
INDEX idx_channel_id (channel_id)
);
Users Table
CREATE TABLE users (
user_id VARCHAR(36) PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100),
profile_picture_url VARCHAR(512),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
INDEX idx_email (email),
INDEX idx_username (username)
);
6.2 NoSQL Database (Cassandra/DynamoDB) - Comments & Engagement
Comments Table (Cassandra Schema)
CREATE TABLE comments (
video_id TEXT,
comment_id TIMEUUID,
user_id TEXT,
parent_comment_id TIMEUUID,
text TEXT,
like_count INT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
PRIMARY KEY (video_id, comment_id)
) WITH CLUSTERING ORDER BY (comment_id DESC);
-- Secondary index for user comments
CREATE TABLE user_comments (
user_id TEXT,
comment_id TIMEUUID,
video_id TEXT,
text TEXT,
created_at TIMESTAMP,
PRIMARY KEY (user_id, comment_id)
) WITH CLUSTERING ORDER BY (comment_id DESC);
-- Index for comment replies
CREATE TABLE comment_replies (
parent_comment_id TIMEUUID,
reply_id TIMEUUID,
user_id TEXT,
text TEXT,
created_at TIMESTAMP,
PRIMARY KEY (parent_comment_id, reply_id)
) WITH CLUSTERING ORDER BY (reply_id DESC);
Watch History (Cassandra Schema)
CREATE TABLE watch_history (
user_id TEXT,
watched_at TIMESTAMP,
video_id TEXT,
watch_duration INT, -- seconds watched
total_duration INT, -- video total duration
quality TEXT, -- resolution watched
PRIMARY KEY (user_id, watched_at, video_id)
) WITH CLUSTERING ORDER BY (watched_at DESC);
-- For recommendations
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
favorite_categories SET<TEXT>,
disliked_categories SET<TEXT>,
preferred_languages SET<TEXT>,
watch_time_total BIGINT,
last_updated TIMESTAMP
);
Video Analytics (Time-Series Data)
CREATE TABLE video_analytics (
video_id TEXT,
time_bucket TIMESTAMP, -- hourly/daily buckets
metric_type TEXT, -- 'views', 'watch_time', 'engagement'
value BIGINT,
PRIMARY KEY ((video_id, metric_type), time_bucket)
) WITH CLUSTERING ORDER BY (time_bucket DESC);
6.3 Redis (Caching Layer)
# Video metadata cache
Key: video:{videoId}
Value: {JSON of video metadata}
TTL: 1 hour
# Video stats cache (hot data)
Key: video:stats:{videoId}
Value: {views: 1000000, likes: 50000, ...}
TTL: 5 minutes
# Trending videos cache
Key: trending:videos:{region}:{category}
Value: [videoId1, videoId2, ...]
TTL: 10 minutes
# User session cache
Key: user:session:{sessionId}
Value: {userId, preferences, ...}
TTL: 24 hours
# View count buffer (before batch update)
Key: video:views:{videoId}
Value: counter (incremented on each view)
Flush to DB: every 5 minutes
# Comment count cache
Key: video:comments:{videoId}
Value: sorted set of recent comments
TTL: 15 minutes
# Recommendation cache
Key: recommendations:{userId}
Value: [videoId1, videoId2, ...]
TTL: 30 minutes
7. Caching Strategy
7.1 Multi-Layer Caching Architecture
┌──────────────────────────────────────────────────────┐
│ CLIENT LAYER │
│ Browser Cache: Video segments (24h) │
│ IndexedDB: Offline video chunks │
└─────────────────┬────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ CDN EDGE CACHE │
│ - Video segments (HLS/DASH): 7 days │
│ - Thumbnails: 30 days │
│ - Popular videos: Indefinite (with LRU) │
│ - Cache hit ratio target: >95% │
└─────────────────┬────────────────────────────────────┘
│ (Cache miss)
▼
┌──────────────────────────────────────────────────────┐
│ APPLICATION CACHE (Redis) │
│ - Video metadata: 1 hour │
│ - User sessions: 24 hours │
│ - Trending videos: 10 minutes │
│ - View counts: 5 minutes │
│ - Recommendations: 30 minutes │
└─────────────────┬────────────────────────────────────┘
│ (Cache miss)
▼
┌──────────────────────────────────────────────────────┐
│ DATABASE LAYER │
│ SQL: Metadata, User data │
│ NoSQL: Comments, Analytics │
│ Object Storage: Video files │
└──────────────────────────────────────────────────────┘
7.2 CDN Strategy
Cache-Control Headers
Video Segments (*.ts, *.m4s):
Cache-Control: public, max-age=604800, immutable
(7 days, never changes once created)
Manifest Files (*.m3u8, *.mpd):
Cache-Control: public, max-age=60
(1 minute, can update for live streams)
Thumbnails:
Cache-Control: public, max-age=2592000
(30 days)
Video Metadata API:
Cache-Control: public, max-age=300
(5 minutes)
CDN Optimization Techniques
1. Geo-Distributed Edge Servers
- User routed to nearest edge location
- Reduces latency from ~200ms to ~20ms
2. Cache Warming
- Pre-populate CDN with popular videos
- Triggered on viral video detection
3. Range Requests
- Support HTTP byte-range requests
- Enable seeking without downloading entire file
4. Compression
- Gzip/Brotli for manifests and metadata
- Video already compressed (H.264/H.265)
5. Cache Tiering
- Hot tier: Most popular videos (SSD)
- Warm tier: Moderately popular (HDD)
- Cold tier: Long-tail (Origin fetch)
7.3 Cache Invalidation Strategy
1. Time-Based Expiration (TTL)
- Most common approach
- Acceptable for view counts, stats
2. Event-Based Invalidation
- Video deleted -> Invalidate all CDN cache
- Video updated -> Invalidate metadata cache
- Comment posted -> Invalidate comment cache
3. Write-Through Cache
- Update cache immediately on write
- Used for critical data (likes, subscriptions)
4. Cache-Aside Pattern
- Application checks cache first
- On miss, fetch from DB and populate cache
- Used for video metadata
8. State Management
8.1 Client-Side State (Video Player)
// Video Player State Machine
{
playbackState: 'playing' | 'paused' | 'buffering' | 'ended',
currentTime: 120.5, // seconds
duration: 600,
bufferedRanges: [[0, 150], [200, 250]], // buffered segments
currentQuality: '720p',
availableQualities: ['2160p', '1080p', '720p', '480p'],
volume: 0.8,
playbackRate: 1.0,
isFullscreen: false,
isMuted: false,
captionsEnabled: true,
// Adaptive bitrate state
networkBandwidth: 5000000, // bps
bufferHealth: 0.8, // 80% healthy
qualitySwitchPending: false,
// Analytics
watchedSegments: [0, 30, 60, 90], // timestamps watched
totalWatchTime: 150, // seconds
bufferingEvents: 2,
qualitySwitches: 3
}
8.2 Server-Side State
Session State (Redis)
{
"sessionId": "sess_abc123",
"userId": "user_456",
"videoId": "video_789",
"currentPosition": 120,
"quality": "720p",
"lastHeartbeat": "2025-01-15T10:05:00Z",
"device": "web",
"location": "US-WEST"
}
Transcoding Job State (DynamoDB)
{
"jobId": "job_xyz",
"videoId": "video_789",
"status": "processing",
"progress": 45,
"currentResolution": "720p",
"completedResolutions": ["240p", "360p", "480p"],
"pendingResolutions": ["1080p", "2160p"],
"startedAt": "2025-01-15T10:00:00Z",
"estimatedCompletion": "2025-01-15T10:15:00Z"
}
8.3 State Synchronization
User watches video on Mobile -> Switches to TV
1. Mobile app sends position update:
POST /api/v1/videos/{videoId}/progress
{ "position": 120, "timestamp": "..." }
2. Server updates Redis:
SET user:video:progress:{userId}:{videoId} "120"
3. TV app polls for progress:
GET /api/v1/videos/{videoId}/progress
Response: { "position": 120 }
4. TV resumes from 120 seconds
9. Performance Optimization
9.1 Video Player Optimizations
Preloading Strategy
// Intelligent preloading
function preloadStrategy(currentTime, duration, bufferHealth) {
const timeRemaining = duration - currentTime;
// Preload more if user is likely to finish video
if (timeRemaining < 60 && bufferHealth > 0.7) {
preloadAhead(30); // 30 seconds ahead
} else if (bufferHealth > 0.8) {
preloadAhead(20);
} else if (bufferHealth < 0.3) {
preloadAhead(10); // Conservative if buffer is low
}
// Preload next video in playlist
if (timeRemaining < 10) {
preloadNextVideo();
}
}
Adaptive Bitrate Algorithm
function selectQuality(bandwidth, bufferHealth, currentQuality) {
// Quality ladder (bitrates in bps)
const qualities = [
{ name: "240p", bitrate: 300000 },
{ name: "360p", bitrate: 800000 },
{ name: "480p", bitrate: 1400000 },
{ name: "720p", bitrate: 2800000 },
{ name: "1080p", bitrate: 5000000 },
{ name: "2160p", bitrate: 8000000 },
];
// Conservative switching (bandwidth * 0.8 safety factor)
const safeBandwidth = bandwidth * 0.8;
// Upscale if buffer is healthy and bandwidth supports
if (bufferHealth > 0.7) {
for (let i = qualities.length - 1; i >= 0; i--) {
if (safeBandwidth >= qualities[i].bitrate) {
return qualities[i].name;
}
}
}
// Downscale immediately if buffering
if (bufferHealth < 0.3) {
const currentIndex = qualities.findIndex((q) => q.name === currentQuality);
return qualities[Math.max(0, currentIndex - 1)].name;
}
return currentQuality;
}
Buffering Strategy
┌─────────────────────────────────────────────────┐
│ Buffer Management Strategy │
│ │
│ Initial Buffer: 5 seconds │
│ Target Buffer: 15-30 seconds │
│ Max Buffer: 60 seconds │
│ │
│ Rebuffering Threshold: < 3 seconds │
│ Quality Switch Threshold: < 10 seconds │
│ │
│ Buffer States: │
│ [0-3s] -> Critical (downscale quality) │
│ [3-10s] -> Low (maintain quality) │
│ [10-30s] -> Healthy (consider upscale) │
│ [30-60s] -> Optimal (upscale if possible) │
│ [60s+] -> Pause preloading │
└─────────────────────────────────────────────────┘
9.2 Thumbnail Optimization
1. Generate Multiple Thumbnails
- Sprite sheet (for seek preview)
- Default thumbnail (720p)
- Small thumbnail (360p for lists)
- WebP format (smaller size)
2. Lazy Loading
- Load thumbnails only when in viewport
- Intersection Observer API
- Placeholder blur image
3. Responsive Images
<img
srcset="thumb_360.webp 360w, thumb_720.webp 720w"
sizes="(max-width: 600px) 360px, 720px"
loading="lazy"
/>
9.3 Database Optimizations
Read Replicas
┌─────────────┐
│ Master │ (Writes: Upload, Update)
│ Database │
└──────┬──────┘
│
│ Replication
├───────────┬───────────┬───────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Read │ │ Read │ │ Read │ │ Read │
│ Replica │ │ Replica │ │ Replica │ │ Replica │
│ (US) │ │ (EU) │ │ (APAC) │ │ (Other) │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
▲ ▲ ▲ ▲
│ │ │ │
[Read] [Read] [Read] [Read] [Read] [Read]
Video metadata, comments, recommendations
Database Indexing
-- Composite indexes for common queries
CREATE INDEX idx_video_channel_date
ON videos(channel_id, upload_date DESC);
CREATE INDEX idx_video_category_views
ON videos(category_id, view_count DESC);
-- Full-text search index
CREATE FULLTEXT INDEX idx_video_search
ON videos(title, description, tags);
-- Covering index (includes all columns needed)
CREATE INDEX idx_video_list
ON videos(status, category_id, upload_date DESC)
INCLUDE (video_id, title, thumbnail_url, duration);
Query Optimization
-- Bad: N+1 query problem
SELECT * FROM videos WHERE channel_id = 'xyz';
-- Then for each video:
SELECT * FROM video_stats WHERE video_id = ?;
-- Good: Single JOIN query
SELECT v.*, vs.view_count, vs.like_count
FROM videos v
LEFT JOIN video_stats vs ON v.video_id = vs.video_id
WHERE v.channel_id = 'xyz'
ORDER BY v.upload_date DESC
LIMIT 20;
-- Use pagination with cursor
SELECT * FROM videos
WHERE upload_date < '2025-01-15T10:00:00Z'
ORDER BY upload_date DESC
LIMIT 20;
9.4 Backend Optimizations
API Response Compression
Gzip compression for JSON responses
Reduces response size by 70-90%
Before: 50KB JSON
After: 5KB Gzipped
Headers:
Content-Encoding: gzip
Accept-Encoding: gzip, deflate, br
Database Connection Pooling
Connection Pool Settings:
Min Connections: 10
Max Connections: 100
Connection Timeout: 30s
Idle Timeout: 600s
Reuse connections instead of creating new ones
Reduces latency from ~100ms to ~5ms per query
Async Processing
Synchronous Upload Flow (Slow):
User -> Upload -> Transcode -> Store -> Return
Total: 10+ minutes
Asynchronous Upload Flow (Fast):
User -> Upload -> Queue Job -> Return
|
v
Background Worker
|
v
Transcode -> Store -> Notify
Total user wait: < 5 seconds
10. Error Handling & Edge Cases
10.1 Video Player Errors
// Comprehensive error handling
class VideoPlayerErrorHandler {
handleError(error) {
switch (error.code) {
case "MEDIA_ERR_ABORTED":
// User aborted playback
this.logError("User aborted", error);
break;
case "MEDIA_ERR_NETWORK":
// Network error during download
this.retryWithBackoff();
this.switchToLowerQuality();
this.showUserMessage("Network error. Retrying...");
break;
case "MEDIA_ERR_DECODE":
// Video decode error
this.switchToAlternateCodec();
this.reportCorruptVideo();
break;
case "MEDIA_ERR_SRC_NOT_SUPPORTED":
// Unsupported video format
this.showUserMessage("Video format not supported");
this.fallbackToFlashPlayer(); // Legacy support
break;
case "MANIFEST_LOAD_ERROR":
// HLS/DASH manifest failed to load
this.retryManifestLoad();
break;
case "SEGMENT_LOAD_ERROR":
// Individual segment failed
this.skipSegment();
this.continuePlayback();
break;
default:
this.showGenericError();
this.reportToMonitoring(error);
}
}
retryWithBackoff() {
const delays = [1000, 2000, 5000, 10000]; // ms
let attempt = 0;
const retry = () => {
if (attempt < delays.length) {
setTimeout(() => {
this.reloadVideo();
attempt++;
}, delays[attempt]);
} else {
this.showUserMessage("Unable to load video. Please try again later.");
}
};
retry();
}
}
10.2 Upload Failures
Upload Error Scenarios:
1. File Too Large (>10GB)
- Reject with clear error message
- Suggest video compression
- Return 413 Payload Too Large
2. Unsupported Format
- Check file extension and MIME type
- Return 415 Unsupported Media Type
- Provide list of supported formats
3. Network Interruption
- Resume upload from last chunk
- Store upload progress in Redis
- Support multipart upload resume
4. Storage Full
- Return 507 Insufficient Storage
- Queue for retry when space available
- Notify admins
5. Virus/Malware Detection
- Scan uploaded file
- Quarantine suspicious files
- Notify user and reject upload
10.3 Transcoding Failures
Transcoding Error Recovery:
1. Worker Failure
- Job returns to queue
- Another worker picks up
- Max retries: 3
2. Corrupted Video File
- Attempt repair with FFmpeg
- If repair fails, notify user
- Mark video as failed
3. Partial Transcoding Success
- 720p succeeds, 1080p fails
- Publish available resolutions
- Retry failed resolutions
4. Timeout (>1 hour)
- Split large video into chunks
- Transcode chunks in parallel
- Merge transcoded chunks
5. Resource Exhaustion
- Scale up worker pool
- Prioritize important videos
- Queue low-priority videos
10.4 CDN Failures
CDN Failure Scenarios:
1. Edge Server Down
- DNS failover to next nearest edge
- Fallback to origin server
- Auto-healing and alerting
2. Cache Corruption
- Invalidate corrupted cache
- Serve from origin
- Re-warm cache
3. Origin Server Unreachable
- Serve stale content (if acceptable)
- Use backup origin server
- Alert operations team
4. DDoS Attack
- Rate limiting at edge
- CAPTCHA for suspicious traffic
- Geo-blocking if needed
10.5 Edge Cases
Concurrent Video Edits
Problem: User uploads video, then immediately updates title/description
Solution:
1. Lock video metadata during initial processing
2. Queue metadata updates
3. Apply updates after processing completes
4. Use optimistic locking with version numbers
Deleted Video Still Cached
Problem: Video deleted but still accessible via CDN
Solution:
1. Immediate cache invalidation on delete
2. Purge CDN cache (max propagation: 5 min)
3. Add "video not found" check in origin
4. Return 404 even if cached (with short TTL)
View Count Inconsistency
Problem: Different view counts across regions
Solution:
1. Accept eventual consistency
2. Batch updates every 5 minutes
3. Use distributed counter (Redis)
4. Periodic reconciliation job
5. Show approximate counts ("1M+" instead of exact)
Live Stream to VOD Transition
Problem: Live stream ends, should become video-on-demand
Solution:
1. Detect stream end event
2. Concatenate live segments
3. Transcode to standard VOD formats
4. Update manifest from live to VOD
5. Archive chat replay alongside video
Seek in Unbuffered Region
Problem: User seeks to 5:00 but only 0:00-1:00 buffered
Solution:
1. Clear existing buffer
2. Request manifest for 5:00 timestamp
3. Load segments starting from 5:00
4. Show loading spinner during seek
5. Resume playback when buffered
11. Interview Cross-Questions
11.1 Scalability Questions
Q: How would you handle 10x traffic spike (e.g., breaking news)?
A: Multi-pronged approach:
- Auto-scaling: Horizontally scale services (API, transcoders, DB read replicas)
- CDN: Most traffic absorbed by CDN edge caches (95%+ hit rate)
- Rate Limiting: Protect backend services from overload
-
Graceful Degradation:
- Disable non-critical features (recommendations, comments)
- Serve lower quality videos
- Queue non-urgent operations
- Load Shedding: Reject requests with 503 when overloaded
- Pre-warming: If spike is predictable, pre-populate CDN caches
Q: How do you handle millions of concurrent uploads?
A:
- Chunked Uploads: Break into 5MB chunks, upload in parallel
- Upload Service Cluster: Horizontal scaling with load balancer
- Queue-Based Transcoding: Decouple upload from processing
- Priority Queue: Prioritize verified channels, smaller videos
- Backpressure: Slow down uploads if queue is full
- Direct S3 Upload: Generate pre-signed URLs, client uploads directly to S3
11.2 Performance Questions
Q: Video start time is 5 seconds. How to reduce to <2 seconds?
A:
- Reduce Initial Manifest Size: Serve only first 30s of manifest
- Preload First Segment: Embed first segment in HTML (inline)
- Adaptive Initial Quality: Start with 360p, upgrade after buffering
- CDN Edge Optimization: Ensure nearest edge has content
- HTTP/2 Server Push: Push manifest + first segment together
- Reduce DNS Lookup: Use DNS prefetching, HTTP keep-alive
- Optimize Encoding: Use faster codec profiles for first segments
Q: How do you optimize for mobile devices with limited bandwidth?
A:
- Aggressive Quality Downscaling: Start with 240p on slow networks
- Reduce Segment Size: 2-second segments instead of 10-second
- Thumbnail Sprites: Single image with all seek thumbnails
- Data Saver Mode: Lower quality, disable autoplay
- Offline Download: Allow download for offline viewing
- Adaptive Preloading: Reduce preload buffer on mobile
- Video Compression: Use H.265/VP9 for better compression
11.3 Consistency & Reliability Questions
Q: How do you ensure view counts are accurate?
A:
- Challenge: Exact accuracy is expensive at scale
-
Solution: Approximate counting with eventual consistency
- Client sends view event after 30s of watch time
- Event logged to Kafka/Kinesis
- Stream processor (Flink) aggregates events in 5-min windows
- Batch update to Redis counter
- Periodic flush to database (every hour)
- Tolerate 5-10 min delay in count updates
- De-duplication: Use session ID + video ID to prevent double-counting
- Bot Detection: Filter out bot traffic, suspicious IPs
Q: What happens if transcoding service crashes mid-job?
A:
- Job Queue with Retry: Job remains in queue until acknowledged
- Worker Heartbeat: Workers send heartbeat every 30s
- Job Timeout: If no heartbeat for 2 min, job returns to queue
- Max Retries: Retry up to 3 times, then mark as failed
- Checkpoint State: Save transcoding progress every 20%
- Resume from Checkpoint: New worker resumes from last checkpoint
- Dead Letter Queue: Failed jobs go to DLQ for manual investigation
11.4 Data Modeling Questions
Q: Why use both SQL and NoSQL databases?
A:
-
SQL (MySQL/PostgreSQL):
- Structured data with strong relationships
- ACID transactions (e.g., user subscriptions)
- Complex queries (e.g., search, recommendations)
- Examples: Videos, Users, Channels
-
NoSQL (Cassandra/DynamoDB):
- High write throughput (comments, analytics)
- Flexible schema (user-generated content)
- Time-series data (watch history, view counts)
- Scalability (billions of comments)
- Examples: Comments, Watch History, Analytics
Q: How do you handle video deletion while ensuring no orphaned data?
A:
- Soft Delete: Mark video as deleted, don't remove immediately
-
Background Cleanup Job:
- Delete all resolutions from S3
- Delete thumbnails
- Delete comments (Cassandra)
- Delete analytics data
- Remove from CDN cache
- Remove from search index
- Cascading Delete: Use database foreign keys with ON DELETE CASCADE
- Async Queue: Queue delete operations for background processing
- Audit Log: Keep deletion record for compliance
- Grace Period: 30-day trash period before permanent deletion
11.5 Cost Optimization Questions
Q: Video storage and bandwidth costs are very high. How to optimize?
A:
- Storage Optimization:
- Delete rarely watched videos (after warning user)
- Archive old videos to cheaper cold storage (Glacier)
- De-duplicate identical videos
- Remove redundant resolutions (e.g., skip 1440p)
- Use better compression (H.265, VP9, AV1)
- Bandwidth Optimization:
- Aggressive CDN caching (reduce origin bandwidth)
- Smart preloading (don't preload if user won't watch)
- Disable autoplay on mobile
- Lower default quality on slow networks
- Use P2P delivery for live streams (WebRTC mesh)
-
Transcoding Optimization:
- Adaptive transcoding (don't generate 4K for short videos)
- On-demand transcoding (only transcode when requested)
- Use cheaper GPU instances for encoding
- Batch transcode jobs during off-peak hours
Q: How do you decide which videos to cache on CDN?
A:
- Multi-factor scoring:
- Popularity: View count, trending score
- Recency: Newly uploaded videos
- Geography: Popular in specific regions
- Channel: Verified channels, high subscriber count
- Content Type: Music videos, viral content
-
Cache Tiers:
- Hot Tier (SSD, all edges): Top 1% most popular
- Warm Tier (HDD, major edges): Top 10%
- Cold Tier (origin fetch): Long-tail content
Eviction Policy: LRU with weighted scoring
11.6 Real-Time Features Questions
Q: How would you implement live streaming?
A:
- Ingest:
- Streamer uses RTMP/WebRTC to push to ingest server
- Ingest server in nearest region
- Transcoding:
- Real-time transcoding to multiple qualities
- Low-latency encoding (<3s delay)
- Distribution:
- HLS for regular live (10-30s delay acceptable)
- WebRTC for ultra-low latency (<1s delay)
- CDN edge caching of live segments
- Playback:
- Adaptive bitrate streaming
- Live DVR (rewind live stream)
- Chat synchronization
-
Fallback:
- Automatic archive to VOD after stream ends
Q: How do you implement real-time comment updates?
A:
- WebSocket Connection: Persistent connection for real-time updates
-
Pub/Sub System: Redis Pub/Sub or Kafka
- User posts comment -> Publish to topic
- All connected clients subscribed to topic receive update
-
Scaling:
- Multiple WebSocket servers behind load balancer
- Sticky sessions for connection persistence
- Redis for cross-server message passing
- Fallback: HTTP long-polling if WebSocket unavailable
- Optimization: Only send updates for visible comments (top 50)
11.7 Security Questions
Q: How do you prevent unauthorized video access?
A:
- Authentication: JWT tokens for user identity
-
Authorization: Check video privacy settings
- Public: Anyone can watch
- Unlisted: Only with direct link
- Private: Only owner/invited users
- Signed URLs: Time-limited, encrypted video URLs
https://cdn.example.com/video.m3u8?token=xyz&expires=1234567890
- Token Rotation: Short-lived tokens (5-15 min)
- DRM: Encrypted video with Widevine/FairPlay for premium content
- Geo-Blocking: Restrict content by region if required
Q: How do you prevent video piracy?
A:
- DRM Encryption: Widevine, FairPlay, PlayReady
- Watermarking: Visible/invisible watermarks with user ID
- HDCP: Prevent screen recording (hardware-level)
- Forensic Watermarking: Trace leaked videos back to source
- Download Restrictions: Disable right-click, inspect element
- Rate Limiting: Prevent bulk downloading
- Legal: DMCA takedown process, content ID matching
12. Trade-offs & Design Decisions
SQL vs NoSQL for Comments
Decision: Use NoSQL (Cassandra)
- Pro: Better write scalability for high-volume comments
- Pro: Time-ordered retrieval (TIMEUUID)
- Con: Limited query flexibility
- Con: Eventual consistency
HLS vs DASH
Decision: Support both, prefer HLS
- HLS: Wider browser support (Safari, iOS)
- DASH: Open standard, better features
- Solution: Serve HLS to Apple devices, DASH to others
Synchronous vs Asynchronous Transcoding
Decision: Asynchronous with job queue
- Pro: Fast upload response (<5s)
- Pro: Decouple upload from processing
- Con: Video not immediately available
- Mitigation: Show processing status, estimate completion time
CDN vs Self-Hosted Streaming
Decision: Use CDN (CloudFront, Akamai)
- Pro: Global edge caching, low latency
- Pro: DDoS protection, high availability
- Con: Expensive for high traffic
- Mitigation: Aggressive caching, P2P for live streams
Exact vs Approximate View Counting
Decision: Approximate counting with 5-min delay
- Pro: Massive scalability improvement
- Pro: Reduced database write load
- Con: Slight delay in count updates
- Why: Users tolerate small delays for view counts
Summary
This design provides a scalable, performant, and reliable video streaming platform similar to YouTube. Key highlights:
- Scalability: Horizontally scalable services, CDN for global reach
- Performance: Adaptive bitrate streaming, multi-layer caching, <2s start time
- Reliability: Queue-based transcoding, retry mechanisms, graceful degradation
- Cost Efficiency: Aggressive caching, smart preloading, compression
- User Experience: Smooth playback, real-time comments, personalized recommendations
The architecture handles millions of concurrent users, supports live and VOD streaming, and provides a YouTube-like experience with adaptive quality, comments, and recommendations.
13. Accessibility (a11y)
Video Player Keyboard Controls
┌─────────────────────────────────────────────────────────────────────────────┐
│ VIDEO PLAYER KEYBOARD SHORTCUTS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Playback Controls: │
│ ────────────────── │
│ Space / K Play / Pause │
│ J Rewind 10 seconds │
│ L Fast forward 10 seconds │
│ ← / → Seek backward/forward 5 seconds │
│ Home Go to beginning │
│ End Go to end │
│ 0-9 Jump to 0%-90% of video │
│ │
│ Volume Controls: │
│ ──────────────── │
│ M Mute / Unmute │
│ ↑ / ↓ Increase / Decrease volume 5% │
│ │
│ Display Controls: │
│ ───────────────── │
│ F Toggle fullscreen │
│ Escape Exit fullscreen │
│ C Toggle captions │
│ < / > Decrease / Increase playback speed │
│ I Toggle mini-player │
│ T Toggle theater mode │
│ │
│ Navigation: │
│ ─────────── │
│ Tab Navigate between controls │
│ Shift+N Next video in playlist │
│ Shift+P Previous video in playlist │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Accessible Video Player Implementation
// AccessibleVideoPlayer.tsx
const AccessibleVideoPlayer = ({ videoId, captions }: VideoPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [captionsEnabled, setCaptionsEnabled] = useState(false);
const announcer = useRef<HTMLDivElement>(null);
// Screen reader announcements
const announce = (message: string) => {
if (announcer.current) {
announcer.current.textContent = message;
// Clear after announcement
setTimeout(() => {
if (announcer.current) announcer.current.textContent = "";
}, 1000);
}
};
// Keyboard handler
const handleKeyDown = (e: KeyboardEvent) => {
const video = videoRef.current;
if (!video) return;
// Don't handle if typing in input
if (e.target instanceof HTMLInputElement) return;
switch (e.key) {
case " ":
case "k":
e.preventDefault();
togglePlay();
break;
case "j":
e.preventDefault();
seekBy(-10);
announce("Rewound 10 seconds");
break;
case "l":
e.preventDefault();
seekBy(10);
announce("Fast forwarded 10 seconds");
break;
case "ArrowLeft":
e.preventDefault();
seekBy(-5);
announce("Rewound 5 seconds");
break;
case "ArrowRight":
e.preventDefault();
seekBy(5);
announce("Fast forwarded 5 seconds");
break;
case "ArrowUp":
e.preventDefault();
adjustVolume(0.05);
break;
case "ArrowDown":
e.preventDefault();
adjustVolume(-0.05);
break;
case "m":
e.preventDefault();
toggleMute();
break;
case "f":
e.preventDefault();
toggleFullscreen();
break;
case "c":
e.preventDefault();
toggleCaptions();
break;
default:
// Number keys 0-9 for percentage seek
if (e.key >= "0" && e.key <= "9") {
e.preventDefault();
const percent = parseInt(e.key) * 10;
seekToPercent(percent);
announce(`Jumped to ${percent}%`);
}
}
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
setIsPlaying(true);
announce("Playing");
} else {
video.pause();
setIsPlaying(false);
announce("Paused");
}
};
const adjustVolume = (delta: number) => {
const video = videoRef.current;
if (!video) return;
const newVolume = Math.max(0, Math.min(1, video.volume + delta));
video.volume = newVolume;
setVolume(newVolume);
announce(`Volume ${Math.round(newVolume * 100)}%`);
};
const toggleCaptions = () => {
setCaptionsEnabled(!captionsEnabled);
announce(captionsEnabled ? "Captions off" : "Captions on");
};
return (
<div
className="video-player-container"
role="application"
aria-label="Video player"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* Screen reader announcements */}
<div
ref={announcer}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
<video
ref={videoRef}
aria-label={`Video: ${title}`}
onTimeUpdate={() => setCurrentTime(videoRef.current?.currentTime || 0)}
onLoadedMetadata={() => setDuration(videoRef.current?.duration || 0)}
>
{captionsEnabled &&
captions.map((caption) => (
<track
key={caption.language}
kind="captions"
src={caption.url}
srcLang={caption.language}
label={caption.label}
default={caption.isDefault}
/>
))}
</video>
{/* Accessible controls */}
<div
className="video-controls"
role="toolbar"
aria-label="Video controls"
>
<button
aria-label={isPlaying ? "Pause" : "Play"}
aria-pressed={isPlaying}
onClick={togglePlay}
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
<div className="timeline-container">
<input
type="range"
aria-label="Video timeline"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={currentTime}
aria-valuetext={formatTime(currentTime)}
value={currentTime}
max={duration}
onChange={(e) => seekTo(parseFloat(e.target.value))}
/>
<span className="sr-only">
{formatTime(currentTime)} of {formatTime(duration)}
</span>
</div>
<button
aria-label={isMuted ? "Unmute" : "Mute"}
aria-pressed={isMuted}
onClick={toggleMute}
>
{isMuted ? <MuteIcon /> : <VolumeIcon />}
</button>
<input
type="range"
aria-label="Volume"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(volume * 100)}
value={volume * 100}
max={100}
onChange={(e) => setVolume(parseFloat(e.target.value) / 100)}
/>
<button
aria-label={
captionsEnabled ? "Turn off captions" : "Turn on captions"
}
aria-pressed={captionsEnabled}
onClick={toggleCaptions}
>
<CaptionsIcon />
</button>
<button aria-label="Enter fullscreen" onClick={toggleFullscreen}>
<FullscreenIcon />
</button>
</div>
</div>
);
};
Captions & Subtitles
// CaptionManager.tsx
interface Caption {
startTime: number;
endTime: number;
text: string;
}
const CaptionManager = ({
captions,
currentTime,
enabled,
style,
}: CaptionManagerProps) => {
const [activeCaption, setActiveCaption] = useState<Caption | null>(null);
useEffect(() => {
if (!enabled) {
setActiveCaption(null);
return;
}
const caption = captions.find(
(c) => currentTime >= c.startTime && currentTime <= c.endTime
);
setActiveCaption(caption || null);
}, [currentTime, captions, enabled]);
if (!activeCaption) return null;
return (
<div
className="caption-container"
role="region"
aria-label="Video captions"
aria-live="off" // Don't announce each caption
style={{
backgroundColor: style.backgroundColor,
color: style.textColor,
fontSize: style.fontSize,
fontFamily: style.fontFamily,
}}
>
{activeCaption.text}
</div>
);
};
// Caption settings panel
const CaptionSettings = ({ onStyleChange }: CaptionSettingsProps) => {
return (
<div
role="dialog"
aria-label="Caption settings"
className="caption-settings"
>
<h3 id="caption-settings-title">Caption Settings</h3>
<div role="group" aria-labelledby="font-size-label">
<label id="font-size-label">Font Size</label>
<select
aria-describedby="font-size-label"
onChange={(e) => onStyleChange("fontSize", e.target.value)}
>
<option value="75%">75%</option>
<option value="100%">100% (Default)</option>
<option value="150%">150%</option>
<option value="200%">200%</option>
</select>
</div>
<div role="group" aria-labelledby="font-family-label">
<label id="font-family-label">Font Family</label>
<select onChange={(e) => onStyleChange("fontFamily", e.target.value)}>
<option value="sans-serif">Sans-serif</option>
<option value="serif">Serif</option>
<option value="monospace">Monospace</option>
</select>
</div>
<div role="group" aria-labelledby="bg-color-label">
<label id="bg-color-label">Background</label>
<select
onChange={(e) => onStyleChange("backgroundColor", e.target.value)}
>
<option value="rgba(0,0,0,0.75)">Black (Default)</option>
<option value="rgba(255,255,255,0.75)">White</option>
<option value="transparent">Transparent</option>
</select>
</div>
</div>
);
};
Focus Management
// useFocusTrap.ts - Trap focus within video player settings
const useFocusTrap = (
isActive: boolean,
containerRef: RefObject<HTMLElement>
) => {
useEffect(() => {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
container.addEventListener("keydown", handleKeyDown);
firstElement?.focus();
return () => {
container.removeEventListener("keydown", handleKeyDown);
};
}, [isActive, containerRef]);
};
// VideoSettingsDialog.tsx
const VideoSettingsDialog = ({ isOpen, onClose }: SettingsDialogProps) => {
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
useFocusTrap(isOpen, dialogRef);
useEffect(() => {
if (isOpen) {
// Store previously focused element
previouslyFocusedRef.current = document.activeElement as HTMLElement;
} else {
// Restore focus when closing
previouslyFocusedRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
className="settings-dialog"
>
<h2 id="settings-title">Video Settings</h2>
{/* Settings content */}
<button onClick={onClose} aria-label="Close settings">
Close
</button>
</div>
);
};
Screen Reader Optimizations
┌─────────────────────────────────────────────────────────────────────────────┐
│ SCREEN READER ANNOUNCEMENTS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Playback Events: │
│ ──────────────── │
│ • "Playing" / "Paused" │
│ • "Video ended" │
│ • "Buffering..." / "Playback resumed" │
│ • "Rewound 10 seconds" │
│ • "Fast forwarded 10 seconds" │
│ │
│ Quality Changes: │
│ ──────────────── │
│ • "Quality changed to 1080p" │
│ • "Auto quality: switching to 720p" │
│ │
│ Volume: │
│ ─────── │
│ • "Volume 50%" │
│ • "Muted" / "Unmuted" │
│ │
│ Captions: │
│ ───────── │
│ • "Captions on: English" │
│ • "Captions off" │
│ │
│ Navigation: │
│ ─────────── │
│ • "Jumped to 50%" │
│ • "Now playing: [Video Title]" │
│ • "Entered fullscreen" / "Exited fullscreen" │
│ │
│ Implementation: │
│ ─────────────── │
│ <div │
│ role="status" │
│ aria-live="polite" │
│ aria-atomic="true" │
│ className="sr-only" │
│ > │
│ {announcement} │
│ </div> │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Video Grid Accessibility
// AccessibleVideoGrid.tsx
const AccessibleVideoGrid = ({ videos }: VideoGridProps) => {
return (
<section aria-label="Video recommendations">
<h2 id="recommendations-heading">Recommended Videos</h2>
<ul
role="list"
aria-labelledby="recommendations-heading"
className="video-grid"
>
{videos.map((video, index) => (
<li key={video.id}>
<article
className="video-card"
aria-labelledby={`video-title-${video.id}`}
>
<a
href={`/watch?v=${video.id}`}
aria-describedby={`video-meta-${video.id}`}
>
<img
src={video.thumbnailUrl}
alt="" // Decorative, title provides context
loading="lazy"
/>
<div className="video-duration" aria-hidden="true">
{formatDuration(video.duration)}
</div>
</a>
<div className="video-info">
<h3 id={`video-title-${video.id}`}>
<a href={`/watch?v=${video.id}`}>{video.title}</a>
</h3>
<p id={`video-meta-${video.id}`} className="video-meta">
<span>{video.channelName}</span>
<span aria-label={`${video.views} views`}>
{formatViews(video.views)} views
</span>
<span aria-label={`uploaded ${video.uploadedAt}`}>
{formatRelativeTime(video.uploadedAt)}
</span>
<span className="sr-only">
Duration: {formatDurationAccessible(video.duration)}
</span>
</p>
</div>
</article>
</li>
))}
</ul>
</section>
);
};
// Accessible duration formatting
const formatDurationAccessible = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const parts = [];
if (hours > 0) parts.push(`${hours} hour${hours > 1 ? "s" : ""}`);
if (minutes > 0) parts.push(`${minutes} minute${minutes > 1 ? "s" : ""}`);
if (secs > 0) parts.push(`${secs} second${secs > 1 ? "s" : ""}`);
return parts.join(" ");
};
14. Security & Content Protection
DRM (Digital Rights Management) Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ DRM ENCRYPTION FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Video Upload → Encode → Encrypt → Store │
│ │
│ ┌──────────┐ │
│ │ Raw │ │
│ │ Video │ │
│ └────┬─────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ ENCODING & ENCRYPTION │ │
│ │ │ │
│ │ 1. Transcode to multiple resolutions │ │
│ │ 2. Generate encryption keys (per video) │ │
│ │ 3. Encrypt each segment with AES-128-CTR │ │
│ │ 4. Create DRM licenses: │ │
│ │ • Widevine (Chrome, Android) │ │
│ │ • FairPlay (Safari, iOS) │ │
│ │ • PlayReady (Edge, Windows) │ │
│ │ 5. Store encrypted segments + license server URLs │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Encrypted│ │ License │ │ Key │ │
│ │ Segments │ │ Server │ │ Server │ │
│ │ (S3) │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Playback Flow: │
│ ─────────────── │
│ 1. Client requests manifest (encrypted video reference) │
│ 2. Client requests license from License Server │
│ 3. License Server validates user subscription/rental │
│ 4. License Server returns decryption key │
│ 5. Client CDM (Content Decryption Module) decrypts video │
│ 6. Decrypted video played in protected path │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Widevine DRM Implementation
// DRMPlayer.tsx - Multi-DRM Video Player
const DRMPlayer = ({ videoId, manifestUrl }: DRMPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const initDRM = async () => {
const video = videoRef.current;
if (!video) return;
// Detect DRM support
const drmConfig = await detectDRMSupport();
if (!drmConfig) {
setError("DRM not supported on this browser");
return;
}
try {
// Initialize Shaka Player with DRM
const shaka = await import("shaka-player");
shaka.polyfill.installAll();
const player = new shaka.Player(video);
player.configure({
drm: {
servers: {
"com.widevine.alpha": `${API_URL}/drm/widevine/license?videoId=${videoId}`,
"com.apple.fps.1_0": `${API_URL}/drm/fairplay/license?videoId=${videoId}`,
"com.microsoft.playready": `${API_URL}/drm/playready/license?videoId=${videoId}`,
},
},
});
// FairPlay requires certificate
if (drmConfig.keySystem === "com.apple.fps.1_0") {
const cert = await fetchFairPlayCertificate();
player.configure(
"drm.advanced.com\\.apple\\.fps\\.1_0.serverCertificate",
cert
);
}
await player.load(manifestUrl);
} catch (err) {
console.error("DRM initialization failed:", err);
setError("Failed to load protected content");
}
};
initDRM();
}, [videoId, manifestUrl]);
return (
<div className="drm-player">
{error && (
<div className="drm-error" role="alert">
<p>{error}</p>
<p>Try using Chrome, Safari, or Edge for protected content.</p>
</div>
)}
<video ref={videoRef} controls />
</div>
);
};
// Detect which DRM system is supported
const detectDRMSupport = async (): Promise<DRMConfig | null> => {
const keySystems = [
{ keySystem: "com.widevine.alpha", name: "Widevine" },
{ keySystem: "com.apple.fps.1_0", name: "FairPlay" },
{ keySystem: "com.microsoft.playready", name: "PlayReady" },
];
for (const config of keySystems) {
try {
const result = await navigator.requestMediaKeySystemAccess(
config.keySystem,
[
{
initDataTypes: ["cenc"],
videoCapabilities: [
{
contentType: 'video/mp4; codecs="avc1.42E01E"',
},
],
},
]
);
if (result) {
return config;
}
} catch (e) {
// This DRM not supported, try next
}
}
return null;
};
Signed URL Implementation
// signedUrl.ts - Generate time-limited signed URLs
interface SignedUrlParams {
videoId: string;
userId: string;
expiresIn: number; // seconds
ipAddress?: string;
}
// Server-side: Generate signed URL
const generateSignedUrl = (params: SignedUrlParams): string => {
const expires = Math.floor(Date.now() / 1000) + params.expiresIn;
const dataToSign = [
params.videoId,
params.userId,
expires.toString(),
params.ipAddress || "",
].join(":");
const signature = crypto
.createHmac("sha256", process.env.URL_SIGNING_SECRET!)
.update(dataToSign)
.digest("hex");
const queryParams = new URLSearchParams({
videoId: params.videoId,
expires: expires.toString(),
signature,
...(params.ipAddress && { ip: params.ipAddress }),
});
return `${CDN_BASE_URL}/videos/${params.videoId}/manifest.m3u8?${queryParams}`;
};
// Client-side: Request signed URL before playback
const getVideoUrl = async (videoId: string): Promise<string> => {
const response = await fetch(`/api/v1/videos/${videoId}/play`, {
headers: {
Authorization: `Bearer ${getAuthToken()}`,
},
});
const { signedUrl, expiresAt } = await response.json();
// Store expiration for refresh
videoUrlCache.set(videoId, { url: signedUrl, expiresAt });
return signedUrl;
};
// Auto-refresh signed URL before expiration
const useSignedUrl = (videoId: string) => {
const [signedUrl, setSignedUrl] = useState<string | null>(null);
const refreshTimer = useRef<NodeJS.Timeout>();
useEffect(() => {
const fetchAndRefresh = async () => {
const response = await getVideoUrl(videoId);
setSignedUrl(response.url);
// Refresh 1 minute before expiration
const refreshIn = response.expiresAt - Date.now() - 60000;
refreshTimer.current = setTimeout(fetchAndRefresh, refreshIn);
};
fetchAndRefresh();
return () => {
if (refreshTimer.current) {
clearTimeout(refreshTimer.current);
}
};
}, [videoId]);
return signedUrl;
};
Content ID & Copyright Detection
┌─────────────────────────────────────────────────────────────────────────────┐
│ CONTENT ID MATCHING FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Upload Flow with Content ID Check: │
│ ────────────────────────────────── │
│ │
│ User Upload │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Extract Audio/ │ │
│ │ Video Fingerprint│ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Compare Against │ │
│ │ Reference DB │ │
│ │ (100M+ tracks) │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ │ │
│ Match? No Match │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Check │ │ Publish │ │
│ │ Policy │ │ Video │ │
│ └────┬────┘ └─────────┘ │
│ │ │
│ ├─────────────┬─────────────┬─────────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Block Monetize Track Only Allow │
│ (Takedown) (Ads for owner) (Analytics) (Licensed) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Age-Restricted Content UI
// AgeVerification.tsx
const AgeVerification = ({ videoId, onVerified }: AgeVerificationProps) => {
const [birthDate, setBirthDate] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const verifyAge = () => {
const birth = new Date(birthDate);
const today = new Date();
const age = Math.floor(
(today.getTime() - birth.getTime()) / (365.25 * 24 * 60 * 60 * 1000)
);
if (age >= 18) {
// Store verification in session
sessionStorage.setItem("ageVerified", "true");
onVerified();
} else {
setError("You must be 18 or older to view this content.");
}
};
return (
<div className="age-verification" role="dialog" aria-labelledby="age-title">
<div className="age-content">
<WarningIcon />
<h2 id="age-title">Age-Restricted Content</h2>
<p>
This video may be inappropriate for some users. Please confirm your
age to continue.
</p>
<div className="age-form">
<label htmlFor="birthdate">Date of Birth</label>
<input
id="birthdate"
type="date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
max={new Date().toISOString().split("T")[0]}
aria-describedby={error ? "age-error" : undefined}
/>
{error && (
<p id="age-error" className="error" role="alert">
{error}
</p>
)}
<button onClick={verifyAge} disabled={!birthDate}>
Confirm Age
</button>
</div>
<p className="privacy-notice">
We don't store your date of birth.
<a href="/privacy">Learn more</a>
</p>
</div>
</div>
);
};
// AgeRestrictedWrapper.tsx
const AgeRestrictedWrapper = ({
video,
children,
}: AgeRestrictedWrapperProps) => {
const [isVerified, setIsVerified] = useState(false);
useEffect(() => {
// Check if already verified this session
const verified = sessionStorage.getItem("ageVerified") === "true";
setIsVerified(verified || !video.isAgeRestricted);
}, [video]);
if (!isVerified) {
return (
<AgeVerification
videoId={video.id}
onVerified={() => setIsVerified(true)}
/>
);
}
return <>{children}</>;
};
Content Moderation UI
// ReportContent.tsx
const ReportContent = ({ videoId, onClose }: ReportContentProps) => {
const [reason, setReason] = useState<string>("");
const [details, setDetails] = useState<string>("");
const [timestamp, setTimestamp] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const reportReasons = [
{ value: "sexual", label: "Sexual content" },
{ value: "violent", label: "Violent or graphic content" },
{ value: "hateful", label: "Hateful or abusive content" },
{ value: "harassment", label: "Harassment or bullying" },
{ value: "spam", label: "Spam or misleading" },
{ value: "copyright", label: "Copyright infringement" },
{ value: "privacy", label: "Privacy violation" },
{ value: "dangerous", label: "Dangerous acts" },
{ value: "child_safety", label: "Child safety concern" },
{ value: "other", label: "Other" },
];
const submitReport = async () => {
setIsSubmitting(true);
try {
await fetch("/api/v1/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
videoId,
reason,
details,
timestamp,
reportedAt: new Date().toISOString(),
}),
});
onClose();
showToast(
"Report submitted. Thank you for helping keep our platform safe."
);
} catch (error) {
showToast("Failed to submit report. Please try again.", "error");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="report-dialog" role="dialog" aria-labelledby="report-title">
<h2 id="report-title">Report Video</h2>
<fieldset>
<legend>What's the issue?</legend>
{reportReasons.map((r) => (
<label key={r.value} className="radio-option">
<input
type="radio"
name="reason"
value={r.value}
checked={reason === r.value}
onChange={() => setReason(r.value)}
/>
{r.label}
</label>
))}
</fieldset>
<div className="form-group">
<label htmlFor="details">Additional details (optional)</label>
<textarea
id="details"
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder="Provide more context about your report..."
maxLength={500}
/>
</div>
<div className="form-group">
<label htmlFor="timestamp">
Timestamp where issue occurs (optional)
</label>
<input
id="timestamp"
type="text"
placeholder="e.g., 2:30"
onChange={(e) => setTimestamp(parseTimestamp(e.target.value))}
/>
</div>
<div className="dialog-actions">
<button onClick={onClose}>Cancel</button>
<button
onClick={submitReport}
disabled={!reason || isSubmitting}
className="primary"
>
{isSubmitting ? "Submitting..." : "Submit Report"}
</button>
</div>
</div>
);
};
HTTPS & Security Headers
┌─────────────────────────────────────────────────────────────────────────────┐
│ SECURITY HEADERS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Essential Headers: │
│ ────────────────── │
│ │
│ # Force HTTPS │
│ Strict-Transport-Security: max-age=31536000; includeSubDomains; preload │
│ │
│ # Prevent clickjacking │
│ X-Frame-Options: DENY │
│ Content-Security-Policy: frame-ancestors 'none' │
│ │
│ # Prevent MIME sniffing │
│ X-Content-Type-Options: nosniff │
│ │
│ # XSS Protection │
│ X-XSS-Protection: 1; mode=block │
│ │
│ # Content Security Policy │
│ Content-Security-Policy: │
│ default-src 'self'; │
│ script-src 'self' 'unsafe-inline' cdn.example.com; │
│ style-src 'self' 'unsafe-inline'; │
│ media-src 'self' blob: cdn.example.com *.cloudfront.net; │
│ img-src 'self' data: cdn.example.com i.ytimg.com; │
│ connect-src 'self' api.example.com wss://ws.example.com; │
│ │
│ # Referrer Policy │
│ Referrer-Policy: strict-origin-when-cross-origin │
│ │
│ # Permissions Policy (disable unnecessary features) │
│ Permissions-Policy: geolocation=(), microphone=(), camera=() │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
15. Mobile & Touch Interactions
Double-Tap to Seek
// DoubleTapSeek.tsx
const DoubleTapSeek = ({ videoRef, seekAmount = 10 }: DoubleTapSeekProps) => {
const [showIndicator, setShowIndicator] = useState<"left" | "right" | null>(
null
);
const [tapCount, setTapCount] = useState(0);
const lastTapTime = useRef(0);
const tapTimeout = useRef<NodeJS.Timeout>();
const handleTap = (e: React.TouchEvent, side: "left" | "right") => {
const now = Date.now();
const timeSinceLastTap = now - lastTapTime.current;
if (timeSinceLastTap < 300) {
// Double tap detected
clearTimeout(tapTimeout.current);
setTapCount((prev) => prev + 1);
const video = videoRef.current;
if (video) {
if (side === "left") {
video.currentTime = Math.max(0, video.currentTime - seekAmount);
} else {
video.currentTime = Math.min(
video.duration,
video.currentTime + seekAmount
);
}
}
setShowIndicator(side);
// Reset after animation
setTimeout(() => {
setShowIndicator(null);
setTapCount(0);
}, 500);
} else {
// Single tap - wait to see if it's a double tap
tapTimeout.current = setTimeout(() => {
// Single tap action (show/hide controls)
toggleControls();
}, 300);
}
lastTapTime.current = now;
};
return (
<div className="double-tap-container">
<div
className="tap-zone tap-zone-left"
onTouchEnd={(e) => handleTap(e, "left")}
>
{showIndicator === "left" && (
<div className="seek-indicator">
<SeekBackIcon />
<span>{tapCount * seekAmount}s</span>
</div>
)}
</div>
<div
className="tap-zone tap-zone-right"
onTouchEnd={(e) => handleTap(e, "right")}
>
{showIndicator === "right" && (
<div className="seek-indicator">
<SeekForwardIcon />
<span>{tapCount * seekAmount}s</span>
</div>
)}
</div>
</div>
);
};
Pinch to Zoom (Video Crop)
// PinchToZoom.tsx
import { useGesture } from "@use-gesture/react";
import { useSpring, animated } from "@react-spring/web";
const PinchToZoom = ({ children }: PinchToZoomProps) => {
const [{ scale, x, y }, api] = useSpring(() => ({
scale: 1,
x: 0,
y: 0,
}));
const containerRef = useRef<HTMLDivElement>(null);
useGesture(
{
onPinch: ({ offset: [s], memo }) => {
// Limit scale between 1x and 3x
const newScale = Math.min(Math.max(s, 1), 3);
api.start({ scale: newScale });
return memo;
},
onPinchEnd: () => {
// Snap back to 1x if close
if (scale.get() < 1.2) {
api.start({ scale: 1, x: 0, y: 0 });
}
},
onDrag: ({ offset: [ox, oy], pinching }) => {
// Only allow pan when zoomed in
if (!pinching && scale.get() > 1) {
api.start({ x: ox, y: oy });
}
},
},
{
target: containerRef,
pinch: { scaleBounds: { min: 1, max: 3 } },
drag: {
from: () => [x.get(), y.get()],
bounds: () => {
const currentScale = scale.get();
const maxOffset = (currentScale - 1) * 100;
return {
left: -maxOffset,
right: maxOffset,
top: -maxOffset,
bottom: maxOffset,
};
},
},
}
);
return (
<div ref={containerRef} className="pinch-container">
<animated.div
style={{
transform: scale.to(
(s) => `scale(${s}) translate(${x.get()}px, ${y.get()}px)`
),
}}
>
{children}
</animated.div>
{scale.get() > 1 && (
<button
className="reset-zoom"
onClick={() => api.start({ scale: 1, x: 0, y: 0 })}
aria-label="Reset zoom"
>
Reset Zoom
</button>
)}
</div>
);
};
Swipe Gestures
┌─────────────────────────────────────────────────────────────────────────────┐
│ VIDEO PLAYER SWIPE GESTURES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Vertical Swipe (Left Side): │
│ ──────────────────────────── │
│ ↑ Swipe Up = Increase brightness │
│ ↓ Swipe Down = Decrease brightness │
│ │
│ Vertical Swipe (Right Side): │
│ ───────────────────────────── │
│ ↑ Swipe Up = Increase volume │
│ ↓ Swipe Down = Decrease volume │
│ │
│ Horizontal Swipe: │
│ ───────────────── │
│ ← Swipe Left = Seek backward (proportional to swipe distance) │
│ → Swipe Right = Seek forward (proportional to swipe distance) │
│ │
│ Implementation: │
│ ─────────────── │
│ const handleSwipe = (direction, distance, side) => { │
│ if (direction === 'vertical') { │
│ const delta = distance / containerHeight * 100; │
│ if (side === 'left') { │
│ adjustBrightness(delta); │
│ } else { │
│ adjustVolume(delta); │
│ } │
│ } else if (direction === 'horizontal') { │
│ const seekTime = (distance / containerWidth) * video.duration * 0.5; │
│ video.currentTime += seekTime; │
│ } │
│ }; │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Picture-in-Picture (PiP)
// PictureInPicture.tsx
const usePictureInPicture = (videoRef: RefObject<HTMLVideoElement>) => {
const [isPiPActive, setIsPiPActive] = useState(false);
const [isPiPSupported, setIsPiPSupported] = useState(false);
useEffect(() => {
// Check PiP support
setIsPiPSupported(
"pictureInPictureEnabled" in document && document.pictureInPictureEnabled
);
const video = videoRef.current;
if (!video) return;
const handleEnterPiP = () => setIsPiPActive(true);
const handleLeavePiP = () => setIsPiPActive(false);
video.addEventListener("enterpictureinpicture", handleEnterPiP);
video.addEventListener("leavepictureinpicture", handleLeavePiP);
return () => {
video.removeEventListener("enterpictureinpicture", handleEnterPiP);
video.removeEventListener("leavepictureinpicture", handleLeavePiP);
};
}, [videoRef]);
const togglePiP = async () => {
const video = videoRef.current;
if (!video) return;
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await video.requestPictureInPicture();
}
} catch (error) {
console.error("PiP failed:", error);
}
};
return { isPiPActive, isPiPSupported, togglePiP };
};
// Auto-enter PiP when scrolling away from video
const useAutoPiP = (
videoRef: RefObject<HTMLVideoElement>,
containerRef: RefObject<HTMLElement>
) => {
const { togglePiP, isPiPActive } = usePictureInPicture(videoRef);
const wasPlaying = useRef(false);
useEffect(() => {
const video = videoRef.current;
const container = containerRef.current;
if (!video || !container) return;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting && !video.paused && !isPiPActive) {
// Video scrolled out of view while playing
wasPlaying.current = true;
togglePiP();
} else if (entry.isIntersecting && isPiPActive && wasPlaying.current) {
// Video scrolled back into view
document.exitPictureInPicture();
wasPlaying.current = false;
}
},
{ threshold: 0.5 }
);
observer.observe(container);
return () => observer.disconnect();
}, [videoRef, containerRef, isPiPActive]);
};
Mini Player
// MiniPlayer.tsx
const MiniPlayer = ({
video,
isActive,
onClose,
onExpand,
}: MiniPlayerProps) => {
const [{ x, y }, api] = useSpring(() => ({ x: 0, y: 0 }));
const containerRef = useRef<HTMLDivElement>(null);
// Drag to reposition
const bind = useDrag(
({ offset: [ox, oy], last }) => {
api.start({ x: ox, y: oy, immediate: !last });
if (last) {
// Snap to corners
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const snapX = ox > windowWidth / 2 ? windowWidth - 320 : 16;
const snapY = oy > windowHeight / 2 ? windowHeight - 180 - 16 : 16;
api.start({ x: snapX, y: snapY });
}
},
{ from: () => [x.get(), y.get()] }
);
// Swipe down to close
const handleSwipeDown = () => {
api.start({ y: window.innerHeight });
setTimeout(onClose, 300);
};
if (!isActive) return null;
return (
<animated.div
ref={containerRef}
{...bind()}
className="mini-player"
style={{
x,
y,
touchAction: "none",
}}
role="complementary"
aria-label="Mini video player"
>
<div className="mini-player-video">
<video src={video.url} autoPlay />
</div>
<div className="mini-player-info">
<p className="video-title">{video.title}</p>
<div className="mini-controls">
<button onClick={() => {}} aria-label="Pause">
<PauseIcon />
</button>
<button onClick={onExpand} aria-label="Expand">
<ExpandIcon />
</button>
<button onClick={onClose} aria-label="Close">
<CloseIcon />
</button>
</div>
</div>
{/* Drag handle */}
<div className="drag-handle" aria-hidden="true" />
</animated.div>
);
};
Mobile Controls Layout
// MobileVideoControls.tsx
const MobileVideoControls = ({
video,
isPlaying,
currentTime,
duration,
onPlayPause,
onSeek,
}: MobileControlsProps) => {
const [showControls, setShowControls] = useState(true);
const hideTimer = useRef<NodeJS.Timeout>();
// Auto-hide controls after 3 seconds
useEffect(() => {
if (isPlaying && showControls) {
hideTimer.current = setTimeout(() => {
setShowControls(false);
}, 3000);
}
return () => clearTimeout(hideTimer.current);
}, [isPlaying, showControls]);
const handleTouch = () => {
setShowControls(true);
clearTimeout(hideTimer.current);
};
return (
<div
className={`mobile-controls ${showControls ? "visible" : "hidden"}`}
onTouchStart={handleTouch}
>
{/* Top bar - title, settings */}
<div className="controls-top">
<button onClick={() => window.history.back()} aria-label="Go back">
<BackIcon />
</button>
<h1 className="video-title">{video.title}</h1>
<button aria-label="Settings">
<SettingsIcon />
</button>
</div>
{/* Center - play/pause, seek buttons */}
<div className="controls-center">
<button
onClick={() => onSeek(currentTime - 10)}
aria-label="Rewind 10 seconds"
>
<RewindIcon />
</button>
<button
onClick={onPlayPause}
aria-label={isPlaying ? "Pause" : "Play"}
className="play-button"
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
<button
onClick={() => onSeek(currentTime + 10)}
aria-label="Forward 10 seconds"
>
<ForwardIcon />
</button>
</div>
{/* Bottom bar - timeline, fullscreen */}
<div className="controls-bottom">
<span className="time">{formatTime(currentTime)}</span>
<input
type="range"
className="timeline"
min={0}
max={duration}
value={currentTime}
onChange={(e) => onSeek(parseFloat(e.target.value))}
aria-label="Video progress"
/>
<span className="time">{formatTime(duration)}</span>
<button aria-label="Toggle fullscreen">
<FullscreenIcon />
</button>
</div>
</div>
);
};
Responsive Video Grid
┌─────────────────────────────────────────────────────────────────────────────┐
│ RESPONSIVE VIDEO GRID │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Breakpoints: │
│ ──────────── │
│ │
│ Mobile (<640px): │
│ ┌─────────────────────────────────────────────┐ │
│ │ [VIDEO THUMBNAIL - Full Width] │ │
│ │ Title │ │
│ │ Channel • 1M views • 2 days ago │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ Tablet (640px - 1024px): │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ [THUMBNAIL] │ │ [THUMBNAIL] │ │
│ │ Title │ │ Title │ │
│ │ Meta │ │ Meta │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Desktop (>1024px): │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │[THUMBNAIL]│ │[THUMBNAIL]│ │[THUMBNAIL]│ │[THUMBNAIL]│ │
│ │ Title │ │ Title │ │ Title │ │ Title │ │
│ │ Meta │ │ Meta │ │ Meta │ │ Meta │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ CSS Implementation: │
│ ─────────────────── │
│ .video-grid { │
│ display: grid; │
│ gap: 16px; │
│ grid-template-columns: 1fr; │
│ } │
│ │
│ @media (min-width: 640px) { │
│ .video-grid { │
│ grid-template-columns: repeat(2, 1fr); │
│ } │
│ } │
│ │
│ @media (min-width: 1024px) { │
│ .video-grid { │
│ grid-template-columns: repeat(4, 1fr); │
│ } │
│ } │
│ │
│ @media (min-width: 1440px) { │
│ .video-grid { │
│ grid-template-columns: repeat(5, 1fr); │
│ } │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
16. Testing Strategy
Video Player Unit Tests
// VideoPlayer.test.tsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { VideoPlayer } from "./VideoPlayer";
// Mock HTMLMediaElement
beforeAll(() => {
// Mock play/pause
window.HTMLMediaElement.prototype.play = jest
.fn()
.mockResolvedValue(undefined);
window.HTMLMediaElement.prototype.pause = jest.fn();
window.HTMLMediaElement.prototype.load = jest.fn();
// Mock seeking
Object.defineProperty(window.HTMLMediaElement.prototype, "currentTime", {
writable: true,
value: 0,
});
Object.defineProperty(window.HTMLMediaElement.prototype, "duration", {
writable: true,
value: 3600, // 1 hour
});
Object.defineProperty(window.HTMLMediaElement.prototype, "paused", {
writable: true,
value: true,
});
});
describe("VideoPlayer", () => {
const mockVideo = {
id: "test-video-1",
title: "Test Video",
url: "https://example.com/video.mp4",
duration: 3600,
};
describe("Playback Controls", () => {
it("should play video when play button is clicked", async () => {
render(<VideoPlayer video={mockVideo} />);
const playButton = screen.getByRole("button", { name: /play/i });
await userEvent.click(playButton);
expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalled();
});
it("should pause video when pause button is clicked", async () => {
render(<VideoPlayer video={mockVideo} autoPlay />);
const pauseButton = await screen.findByRole("button", { name: /pause/i });
await userEvent.click(pauseButton);
expect(window.HTMLMediaElement.prototype.pause).toHaveBeenCalled();
});
it("should toggle play/pause with spacebar", async () => {
const { container } = render(<VideoPlayer video={mockVideo} />);
container.focus();
await userEvent.keyboard(" ");
expect(window.HTMLMediaElement.prototype.play).toHaveBeenCalled();
});
it("should seek forward 10 seconds with arrow right", async () => {
const { container } = render(<VideoPlayer video={mockVideo} />);
const videoElement = container.querySelector("video");
videoElement!.currentTime = 100;
container.focus();
await userEvent.keyboard("{ArrowRight}");
expect(videoElement!.currentTime).toBe(110);
});
});
describe("Timeline/Progress Bar", () => {
it("should display current time and duration", () => {
render(<VideoPlayer video={mockVideo} />);
expect(screen.getByText("0:00")).toBeInTheDocument();
expect(screen.getByText("1:00:00")).toBeInTheDocument();
});
it("should update progress bar on time update", async () => {
const { container } = render(<VideoPlayer video={mockVideo} />);
const videoElement = container.querySelector("video");
// Simulate time update
Object.defineProperty(videoElement, "currentTime", { value: 1800 });
fireEvent.timeUpdate(videoElement!);
await waitFor(() => {
const progressBar = screen.getByRole("slider", { name: /progress/i });
expect(progressBar).toHaveValue("1800");
});
});
it("should seek when clicking on progress bar", async () => {
const { container } = render(<VideoPlayer video={mockVideo} />);
const progressBar = screen.getByRole("slider", { name: /progress/i });
const videoElement = container.querySelector("video");
fireEvent.change(progressBar, { target: { value: "1800" } });
expect(videoElement!.currentTime).toBe(1800);
});
});
describe("Volume Controls", () => {
it("should mute/unmute with M key", async () => {
const { container } = render(<VideoPlayer video={mockVideo} />);
const videoElement = container.querySelector("video");
container.focus();
await userEvent.keyboard("m");
expect(videoElement!.muted).toBe(true);
await userEvent.keyboard("m");
expect(videoElement!.muted).toBe(false);
});
it("should update volume slider", async () => {
const { container } = render(<VideoPlayer video={mockVideo} />);
const volumeSlider = screen.getByRole("slider", { name: /volume/i });
const videoElement = container.querySelector("video");
fireEvent.change(volumeSlider, { target: { value: "0.5" } });
expect(videoElement!.volume).toBe(0.5);
});
});
describe("Fullscreen", () => {
it("should toggle fullscreen with F key", async () => {
const mockRequestFullscreen = jest.fn();
document.documentElement.requestFullscreen = mockRequestFullscreen;
const { container } = render(<VideoPlayer video={mockVideo} />);
container.focus();
await userEvent.keyboard("f");
expect(mockRequestFullscreen).toHaveBeenCalled();
});
});
});
HLS.js Streaming Tests
// StreamingPlayer.test.tsx
import Hls from "hls.js";
import { render, waitFor, screen } from "@testing-library/react";
import { StreamingPlayer } from "./StreamingPlayer";
// Mock HLS.js
jest.mock("hls.js", () => {
const mockHls = {
loadSource: jest.fn(),
attachMedia: jest.fn(),
on: jest.fn(),
destroy: jest.fn(),
currentLevel: 0,
levels: [
{ height: 360, bitrate: 800000 },
{ height: 720, bitrate: 2500000 },
{ height: 1080, bitrate: 5000000 },
],
};
const HlsConstructor = jest.fn(() => mockHls);
HlsConstructor.isSupported = jest.fn(() => true);
HlsConstructor.Events = {
MANIFEST_PARSED: "hlsManifestParsed",
LEVEL_SWITCHED: "hlsLevelSwitched",
ERROR: "hlsError",
BUFFER_APPENDED: "hlsBufferAppended",
FRAG_LOADED: "hlsFragLoaded",
};
HlsConstructor.ErrorTypes = {
NETWORK_ERROR: "networkError",
MEDIA_ERROR: "mediaError",
};
return HlsConstructor;
});
describe("StreamingPlayer", () => {
const mockManifest = "https://cdn.example.com/video/master.m3u8";
beforeEach(() => {
jest.clearAllMocks();
});
it("should initialize HLS.js when supported", async () => {
render(<StreamingPlayer manifestUrl={mockManifest} />);
await waitFor(() => {
expect(Hls).toHaveBeenCalled();
});
const hlsInstance = (Hls as jest.Mock).mock.results[0].value;
expect(hlsInstance.loadSource).toHaveBeenCalledWith(mockManifest);
expect(hlsInstance.attachMedia).toHaveBeenCalled();
});
it("should handle manifest parsed event", async () => {
render(<StreamingPlayer manifestUrl={mockManifest} />);
await waitFor(() => {
const hlsInstance = (Hls as jest.Mock).mock.results[0].value;
const onCall = hlsInstance.on.mock.calls.find(
([event]: [string]) => event === Hls.Events.MANIFEST_PARSED
);
expect(onCall).toBeDefined();
});
});
it("should display quality selector after manifest loads", async () => {
render(<StreamingPlayer manifestUrl={mockManifest} showQualitySelector />);
// Simulate manifest parsed event
await waitFor(() => {
const hlsInstance = (Hls as jest.Mock).mock.results[0].value;
const callback = hlsInstance.on.mock.calls.find(
([event]: [string]) => event === Hls.Events.MANIFEST_PARSED
)?.[1];
callback?.();
});
expect(await screen.findByText("720p")).toBeInTheDocument();
expect(screen.getByText("1080p")).toBeInTheDocument();
});
it("should clean up HLS instance on unmount", () => {
const { unmount } = render(<StreamingPlayer manifestUrl={mockManifest} />);
unmount();
const hlsInstance = (Hls as jest.Mock).mock.results[0].value;
expect(hlsInstance.destroy).toHaveBeenCalled();
});
it("should handle network errors with retry", async () => {
const onError = jest.fn();
render(<StreamingPlayer manifestUrl={mockManifest} onError={onError} />);
await waitFor(() => {
const hlsInstance = (Hls as jest.Mock).mock.results[0].value;
const errorCallback = hlsInstance.on.mock.calls.find(
([event]: [string]) => event === Hls.Events.ERROR
)?.[1];
errorCallback?.(Hls.Events.ERROR, {
type: Hls.ErrorTypes.NETWORK_ERROR,
fatal: true,
details: "manifestLoadError",
});
});
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
type: "networkError",
})
);
});
});
// ABR (Adaptive Bitrate) Tests
describe("Adaptive Bitrate Switching", () => {
it("should auto-select quality based on bandwidth", async () => {
const onQualityChange = jest.fn();
render(
<StreamingPlayer
manifestUrl="https://cdn.example.com/video.m3u8"
onQualityChange={onQualityChange}
/>
);
await waitFor(() => {
const hlsInstance = (Hls as jest.Mock).mock.results[0].value;
const levelCallback = hlsInstance.on.mock.calls.find(
([event]: [string]) => event === Hls.Events.LEVEL_SWITCHED
)?.[1];
levelCallback?.(Hls.Events.LEVEL_SWITCHED, { level: 2 });
});
expect(onQualityChange).toHaveBeenCalledWith(
expect.objectContaining({ height: 1080 })
);
});
it("should allow manual quality selection", async () => {
render(
<StreamingPlayer
manifestUrl="https://cdn.example.com/video.m3u8"
showQualitySelector
/>
);
// Select 720p
const qualityButton = await screen.findByRole("button", { name: /720p/i });
await userEvent.click(qualityButton);
const hlsInstance = (Hls as jest.Mock).mock.results[0].value;
expect(hlsInstance.currentLevel).toBe(1);
});
});
Playback State Machine Tests
// useVideoState.test.ts
import { renderHook, act } from "@testing-library/react";
import { useVideoState } from "./useVideoState";
describe("useVideoState", () => {
it("should start in idle state", () => {
const { result } = renderHook(() => useVideoState());
expect(result.current.state).toBe("idle");
});
it("should transition to loading when load action dispatched", () => {
const { result } = renderHook(() => useVideoState());
act(() => {
result.current.dispatch({ type: "LOAD", videoId: "video-1" });
});
expect(result.current.state).toBe("loading");
});
it("should transition to playing when canPlay + play", () => {
const { result } = renderHook(() => useVideoState());
act(() => {
result.current.dispatch({ type: "LOAD", videoId: "video-1" });
result.current.dispatch({ type: "CAN_PLAY" });
result.current.dispatch({ type: "PLAY" });
});
expect(result.current.state).toBe("playing");
});
it("should handle buffering state", () => {
const { result } = renderHook(() => useVideoState());
act(() => {
result.current.dispatch({ type: "LOAD", videoId: "video-1" });
result.current.dispatch({ type: "CAN_PLAY" });
result.current.dispatch({ type: "PLAY" });
result.current.dispatch({ type: "WAITING" });
});
expect(result.current.state).toBe("buffering");
expect(result.current.wasPlaying).toBe(true);
});
it("should resume playing after buffering", () => {
const { result } = renderHook(() => useVideoState());
act(() => {
result.current.dispatch({ type: "LOAD", videoId: "video-1" });
result.current.dispatch({ type: "CAN_PLAY" });
result.current.dispatch({ type: "PLAY" });
result.current.dispatch({ type: "WAITING" });
result.current.dispatch({ type: "CAN_PLAY" });
});
expect(result.current.state).toBe("playing");
});
it("should handle error state", () => {
const { result } = renderHook(() => useVideoState());
act(() => {
result.current.dispatch({ type: "LOAD", videoId: "video-1" });
result.current.dispatch({
type: "ERROR",
error: { code: 4, message: "Network error" },
});
});
expect(result.current.state).toBe("error");
expect(result.current.error).toEqual({ code: 4, message: "Network error" });
});
it("should handle ended state", () => {
const { result } = renderHook(() => useVideoState());
act(() => {
result.current.dispatch({ type: "LOAD", videoId: "video-1" });
result.current.dispatch({ type: "CAN_PLAY" });
result.current.dispatch({ type: "PLAY" });
result.current.dispatch({ type: "ENDED" });
});
expect(result.current.state).toBe("ended");
});
});
// State machine diagram
┌─────────────────────────────────────────────────────────────────────────────┐
│ VIDEO PLAYER STATE MACHINE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ │
│ │ idle │ │
│ └────┬────┘ │
│ │ LOAD │
│ ▼ │
│ ┌──────────┐ │
│ ┌───►│ loading │◄────┐ │
│ │ └────┬─────┘ │ │
│ │ │ │ │
│ ERROR │ CAN_PLAY ERROR │ │
│ │ │ │ │
│ │ ▼ │ │
│ ┌─────────┐ │ ┌─────────┐ │ ┌─────────┐ │
│ │ error │◄───────┴────│ ready │──────┴───────►│ ended │ │
│ └─────────┘ └────┬────┘ └────┬────┘ │
│ ▲ │ │ │
│ │ PLAY │ LOAD │ │
│ │ ▼ │ │
│ │ ┌──────────┐ │ │
│ │ ERROR ┌────►│ playing │◄───────────────────┘ │
│ │ │ └────┬─────┘ │
│ │ PLAY │ │ │
│ │ │ PAUSE │ WAITING │
│ │ │ │ │ │
│ │ │ ▼ ▼ │
│ │ │ ┌──────────────┐ │
│ └───────────┼────│ paused │ │
│ │ └──────────────┘ │
│ │ ▲ │
│ │ │ │
│ │ CAN_PLAY │
│ │ │ │
│ │ ┌─────┴──────┐ │
│ └────│ buffering │ │
│ └────────────┘ │
│ │
│ State Transitions: │
│ ───────────────── │
│ • idle → loading: Video URL loaded │
│ • loading → ready: Enough data buffered │
│ • ready → playing: Play triggered │
│ • playing → paused: Pause triggered │
│ • playing → buffering: Buffer underrun │
│ • buffering → playing: Buffer refilled │
│ • playing → ended: Video finished │
│ • any → error: Fatal error occurred │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
E2E Streaming Tests
// streaming.e2e.test.ts
import { test, expect, Page } from "@playwright/test";
test.describe("Video Streaming E2E", () => {
let page: Page;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
await page.goto("/watch/test-video-1");
});
test("should load video player", async () => {
const player = page.locator('[data-testid="video-player"]');
await expect(player).toBeVisible();
const video = page.locator("video");
await expect(video).toHaveAttribute("src");
});
test("should play video on click", async () => {
const playButton = page.getByRole("button", { name: /play/i });
await playButton.click();
const video = page.locator("video");
await expect(video).toHaveJSProperty("paused", false);
});
test("should display buffered progress", async () => {
const video = page.locator("video");
// Wait for some buffering
await page.waitForFunction(() => {
const v = document.querySelector("video");
return v && v.buffered.length > 0 && v.buffered.end(0) > 0;
});
const bufferBar = page.locator('[data-testid="buffer-progress"]');
await expect(bufferBar).toHaveCSS("width", /.+/);
});
test("should switch quality levels", async () => {
// Open quality menu
const qualityButton = page.getByRole("button", { name: /quality/i });
await qualityButton.click();
// Select 720p
const quality720 = page.getByRole("menuitem", { name: /720p/i });
await quality720.click();
// Verify quality changed
await expect(page.locator('[data-testid="current-quality"]')).toHaveText(
"720p"
);
});
test("should handle network interruption gracefully", async () => {
// Start playing
await page.click('[data-testid="play-button"]');
// Simulate network offline
await page.context().setOffline(true);
// Wait for buffering indicator
const bufferingIndicator = page.locator(
'[data-testid="buffering-spinner"]'
);
await expect(bufferingIndicator).toBeVisible({ timeout: 10000 });
// Restore network
await page.context().setOffline(false);
// Should resume playing
await expect(bufferingIndicator).not.toBeVisible({ timeout: 30000 });
const video = page.locator("video");
await expect(video).toHaveJSProperty("paused", false);
});
test("should persist playback position on refresh", async () => {
// Play and seek to 30 seconds
await page.click('[data-testid="play-button"]');
const video = page.locator("video");
await video.evaluate((v: HTMLVideoElement) => {
v.currentTime = 30;
});
// Wait for position to be saved
await page.waitForTimeout(1000);
// Refresh page
await page.reload();
// Verify resumed position
await page.waitForFunction(() => {
const v = document.querySelector("video");
return v && v.currentTime >= 28; // Allow 2 second tolerance
});
});
test("should track watch progress", async () => {
// Play video
await page.click('[data-testid="play-button"]');
// Wait for some progress
await page.waitForTimeout(5000);
// Navigate away
await page.goto("/");
// Check that progress is saved
const continueWatching = page.locator('[data-testid="continue-watching"]');
await expect(continueWatching).toContainText("Test Video");
});
});
// Caption/Subtitle Tests
test.describe("Captions", () => {
test("should toggle captions", async ({ page }) => {
await page.goto("/watch/video-with-captions");
const captionButton = page.getByRole("button", { name: /captions/i });
await captionButton.click();
// Select English
await page.getByRole("menuitem", { name: /english/i }).click();
// Verify captions visible
const captionDisplay = page.locator(".caption-container");
await expect(captionDisplay).toBeVisible();
});
test("should sync captions with video time", async ({ page }) => {
await page.goto("/watch/video-with-captions");
// Enable captions
await page.getByRole("button", { name: /captions/i }).click();
await page.getByRole("menuitem", { name: /english/i }).click();
// Seek to known caption time
const video = page.locator("video");
await video.evaluate((v: HTMLVideoElement) => {
v.currentTime = 10;
});
// Verify caption content
const caption = page.locator(".caption-text");
await expect(caption).toHaveText(/Expected caption text/);
});
});
Testing Utilities for Video
// testUtils.ts
import { screen, waitFor } from "@testing-library/react";
// Wait for video to be ready
export const waitForVideoReady = async () => {
await waitFor(
() => {
const video = document.querySelector("video");
expect(video?.readyState).toBeGreaterThanOrEqual(3);
},
{ timeout: 10000 }
);
};
// Mock video element with full API
export const createMockVideoElement = (
overrides?: Partial<HTMLVideoElement>
) => {
const eventListeners: Record<string, Set<EventListener>> = {};
return {
play: jest.fn().mockResolvedValue(undefined),
pause: jest.fn(),
load: jest.fn(),
currentTime: 0,
duration: 3600,
paused: true,
muted: false,
volume: 1,
playbackRate: 1,
buffered: {
length: 1,
start: () => 0,
end: () => 100,
},
videoWidth: 1920,
videoHeight: 1080,
readyState: 4,
addEventListener: jest.fn((event: string, listener: EventListener) => {
if (!eventListeners[event]) {
eventListeners[event] = new Set();
}
eventListeners[event].add(listener);
}),
removeEventListener: jest.fn((event: string, listener: EventListener) => {
eventListeners[event]?.delete(listener);
}),
dispatchEvent: jest.fn((event: Event) => {
const listeners = eventListeners[event.type];
listeners?.forEach((listener) => listener(event));
return true;
}),
requestPictureInPicture: jest.fn().mockResolvedValue({}),
...overrides,
} as unknown as HTMLVideoElement;
};
// Simulate streaming events
export const simulateStreamingEvents = async (
hls: any,
sequence: Array<{ event: string; data: any; delay?: number }>
) => {
for (const { event, data, delay = 0 } of sequence) {
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
const callback = hls.on.mock.calls.find(
([e]: [string]) => e === event
)?.[1];
callback?.(event, data);
}
};
// Wait for quality switch
export const waitForQualitySwitch = async (targetHeight: number) => {
await waitFor(() => {
const qualityIndicator = screen.getByTestId("current-quality");
expect(qualityIndicator).toHaveTextContent(`${targetHeight}p`);
});
};
Test Coverage Requirements
┌─────────────────────────────────────────────────────────────────────────────┐
│ VIDEO PLAYER TEST COVERAGE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Coverage Requirements by Category: │
│ ───────────────────────────────── │
│ │
│ ┌───────────────────────────┬──────────┬───────────────────────────────┐ │
│ │ Category │ Min % │ Critical Areas │ │
│ ├───────────────────────────┼──────────┼───────────────────────────────┤ │
│ │ Video Player Controls │ 95% │ Play/Pause, Seek, Volume │ │
│ │ Streaming (HLS/DASH) │ 90% │ ABR, Error recovery │ │
│ │ DRM Integration │ 85% │ License fetch, Key rotation │ │
│ │ Playback State Machine │ 100% │ All state transitions │ │
│ │ Captions/Subtitles │ 90% │ Timing sync, Style rendering │ │
│ │ Keyboard Shortcuts │ 95% │ All key bindings │ │
│ │ Picture-in-Picture │ 85% │ Enter/exit, Visibility │ │
│ │ Progress Persistence │ 90% │ Save/restore position │ │
│ │ Quality Selection │ 90% │ Manual/Auto switching │ │
│ │ Error Handling │ 95% │ All error codes │ │
│ └───────────────────────────┴──────────┴───────────────────────────────┘ │
│ │
│ Test Types Distribution: │
│ ──────────────────────── │
│ │
│ Unit Tests: 60% ████████████████████████░░░░░░░░░░░░░ │
│ Integration Tests: 25% ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ E2E Tests: 15% ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ │
│ Mocking Strategy: │
│ ───────────────── │
│ • HTMLMediaElement - Mock play/pause/seek │
│ • HLS.js/DASH.js - Mock entire library │
│ • Network requests - MSW for API mocking │
│ • MediaKeySession - Mock for DRM tests │
│ • IntersectionObserver - Mock for visibility tests │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
17. Offline/PWA Capabilities
Service Worker for Video Caching
// sw.ts - Service Worker for video streaming PWA
declare const self: ServiceWorkerGlobalScope;
const CACHE_NAME = "video-streaming-v1";
const VIDEO_CACHE = "video-cache-v1";
const STATIC_ASSETS = [
"/",
"/index.html",
"/manifest.json",
"/offline.html",
"/static/js/main.js",
"/static/css/main.css",
];
// Install event - cache static assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// Activate event - clean old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== VIDEO_CACHE)
.map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// Fetch event - serve from cache or network
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Handle video segment requests
if (url.pathname.includes("/segments/") || url.pathname.endsWith(".ts")) {
event.respondWith(handleVideoSegment(request));
return;
}
// Handle HLS manifest requests
if (url.pathname.endsWith(".m3u8")) {
event.respondWith(handleManifest(request));
return;
}
// Handle API requests
if (url.pathname.startsWith("/api/")) {
event.respondWith(handleApiRequest(request));
return;
}
// Default: Network first, fallback to cache
event.respondWith(
fetch(request)
.then((response) => {
// Cache successful GET requests
if (request.method === "GET" && response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
}
return response;
})
.catch(() => {
return caches.match(request).then((cached) => {
return cached || caches.match("/offline.html");
});
})
);
});
// Handle video segment caching with range requests
async function handleVideoSegment(request: Request): Promise<Response> {
const cache = await caches.open(VIDEO_CACHE);
const cached = await cache.match(request);
if (cached) {
return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
// Only cache if content-length is reasonable (< 10MB per segment)
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength) < 10 * 1024 * 1024) {
cache.put(request, response.clone());
}
}
return response;
} catch (error) {
// Return offline placeholder for video
return new Response("Video segment unavailable offline", {
status: 503,
headers: { "Content-Type": "text/plain" },
});
}
}
// Handle HLS manifest with network-first strategy
async function handleManifest(request: Request): Promise<Response> {
try {
const response = await fetch(request);
const cache = await caches.open(VIDEO_CACHE);
cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
throw error;
}
}
// Handle API requests with stale-while-revalidate
async function handleApiRequest(request: Request): Promise<Response> {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
});
return cached || fetchPromise;
}
// Background sync for watch history
self.addEventListener("sync", (event: SyncEvent) => {
if (event.tag === "sync-watch-history") {
event.waitUntil(syncWatchHistory());
}
});
async function syncWatchHistory(): Promise<void> {
const db = await openIndexedDB();
const pendingUpdates = await db.getAll("pending-history");
for (const update of pendingUpdates) {
try {
await fetch("/api/v1/watch-history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(update),
});
await db.delete("pending-history", update.id);
} catch (error) {
// Will retry on next sync
console.error("Failed to sync watch history:", error);
}
}
}
Download Manager for Offline Viewing
// DownloadManager.tsx
interface DownloadedVideo {
id: string;
title: string;
thumbnail: string;
duration: number;
quality: string;
size: number;
downloadedAt: number;
expiresAt: number;
segments: string[];
}
const DownloadManager = () => {
const [downloads, setDownloads] = useState<DownloadedVideo[]>([]);
const [activeDownloads, setActiveDownloads] = useState<Map<string, number>>(
new Map()
);
const [storageUsed, setStorageUsed] = useState(0);
const [storageQuota, setStorageQuota] = useState(0);
// Check storage quota
useEffect(() => {
const checkStorage = async () => {
if ("storage" in navigator && "estimate" in navigator.storage) {
const estimate = await navigator.storage.estimate();
setStorageUsed(estimate.usage || 0);
setStorageQuota(estimate.quota || 0);
}
};
checkStorage();
}, [downloads]);
// Load downloaded videos from IndexedDB
useEffect(() => {
const loadDownloads = async () => {
const db = await openDB("video-downloads", 1, {
upgrade(db) {
db.createObjectStore("videos", { keyPath: "id" });
db.createObjectStore("segments");
},
});
const videos = await db.getAll("videos");
setDownloads(videos);
};
loadDownloads();
}, []);
const downloadVideo = async (videoId: string, quality: string) => {
// Get video manifest and metadata
const response = await fetch(
`/api/v1/videos/${videoId}/download?quality=${quality}`
);
const { manifest, metadata, segments } = await response.json();
const db = await openDB("video-downloads", 1);
// Download segments with progress tracking
let downloadedCount = 0;
const totalSegments = segments.length;
for (const segmentUrl of segments) {
const segmentResponse = await fetch(segmentUrl);
const blob = await segmentResponse.blob();
// Store segment in IndexedDB
await db.put("segments", blob, `${videoId}:${segmentUrl}`);
downloadedCount++;
setActiveDownloads((prev) => {
const updated = new Map(prev);
updated.set(videoId, (downloadedCount / totalSegments) * 100);
return updated;
});
}
// Store video metadata
const downloadedVideo: DownloadedVideo = {
id: videoId,
title: metadata.title,
thumbnail: metadata.thumbnail,
duration: metadata.duration,
quality,
size: segments.reduce((acc: number, _: string) => acc, 0),
downloadedAt: Date.now(),
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
segments,
};
await db.put("videos", downloadedVideo);
setDownloads((prev) => [...prev, downloadedVideo]);
setActiveDownloads((prev) => {
const updated = new Map(prev);
updated.delete(videoId);
return updated;
});
};
const deleteDownload = async (videoId: string) => {
const db = await openDB("video-downloads", 1);
// Get video to find segments
const video = await db.get("videos", videoId);
if (video) {
// Delete all segments
for (const segmentUrl of video.segments) {
await db.delete("segments", `${videoId}:${segmentUrl}`);
}
}
// Delete video metadata
await db.delete("videos", videoId);
setDownloads((prev) => prev.filter((v) => v.id !== videoId));
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
return (
<div className="download-manager">
{/* Storage indicator */}
<div className="storage-info">
<div className="storage-bar">
<div
className="storage-used"
style={{ width: `${(storageUsed / storageQuota) * 100}%` }}
/>
</div>
<p>
{formatBytes(storageUsed)} of {formatBytes(storageQuota)} used
</p>
</div>
{/* Active downloads */}
{activeDownloads.size > 0 && (
<section className="active-downloads">
<h2>Downloading</h2>
{Array.from(activeDownloads.entries()).map(([id, progress]) => (
<div key={id} className="download-progress">
<span>{id}</span>
<progress value={progress} max={100} />
<span>{Math.round(progress)}%</span>
</div>
))}
</section>
)}
{/* Downloaded videos */}
<section className="downloaded-videos">
<h2>Downloaded ({downloads.length})</h2>
{downloads.map((video) => (
<div key={video.id} className="downloaded-video-card">
<img src={video.thumbnail} alt={video.title} />
<div className="video-info">
<h3>{video.title}</h3>
<p>
{video.quality} • {formatBytes(video.size)}
</p>
<p className="expires">
Expires {new Date(video.expiresAt).toLocaleDateString()}
</p>
</div>
<div className="video-actions">
<button
onClick={() =>
(window.location.href = `/watch/${video.id}?offline=true`)
}
aria-label={`Play ${video.title}`}
>
<PlayIcon />
</button>
<button
onClick={() => deleteDownload(video.id)}
aria-label={`Delete ${video.title}`}
>
<DeleteIcon />
</button>
</div>
</div>
))}
</section>
</div>
);
};
Offline Video Player
// OfflineVideoPlayer.tsx
const OfflineVideoPlayer = ({ videoId }: { videoId: string }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const mediaSourceRef = useRef<MediaSource | null>(null);
const sourceBufferRef = useRef<SourceBuffer | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadOfflineVideo = async () => {
try {
const db = await openDB("video-downloads", 1);
const video = await db.get("videos", videoId);
if (!video) {
setError("Video not found offline");
return;
}
// Check if video has expired
if (Date.now() > video.expiresAt) {
setError("Download has expired. Please re-download.");
return;
}
// Create MediaSource for segmented playback
const mediaSource = new MediaSource();
mediaSourceRef.current = mediaSource;
videoRef.current!.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener("sourceopen", async () => {
const sourceBuffer = mediaSource.addSourceBuffer(
'video/mp4; codecs="avc1.640028"'
);
sourceBufferRef.current = sourceBuffer;
// Load segments from IndexedDB
for (const segmentUrl of video.segments) {
const segmentData = await db.get(
"segments",
`${videoId}:${segmentUrl}`
);
if (segmentData) {
await appendToBuffer(sourceBuffer, segmentData);
}
}
mediaSource.endOfStream();
setIsLoading(false);
});
} catch (err) {
setError("Failed to load offline video");
console.error(err);
}
};
loadOfflineVideo();
return () => {
if (mediaSourceRef.current) {
URL.revokeObjectURL(videoRef.current?.src || "");
}
};
}, [videoId]);
const appendToBuffer = (
sourceBuffer: SourceBuffer,
data: ArrayBuffer
): Promise<void> => {
return new Promise((resolve, reject) => {
if (sourceBuffer.updating) {
sourceBuffer.addEventListener(
"updateend",
() => {
appendToBuffer(sourceBuffer, data).then(resolve).catch(reject);
},
{ once: true }
);
return;
}
try {
sourceBuffer.appendBuffer(data);
sourceBuffer.addEventListener("updateend", () => resolve(), {
once: true,
});
} catch (err) {
reject(err);
}
});
};
if (error) {
return (
<div className="offline-error" role="alert">
<OfflineIcon />
<p>{error}</p>
<button onClick={() => (window.location.href = `/watch/${videoId}`)}>
Try Online
</button>
</div>
);
}
return (
<div className="offline-player">
{isLoading && (
<div className="loading-overlay">
<Spinner />
<p>Loading offline video...</p>
</div>
)}
<video ref={videoRef} controls playsInline className="video-element" />
<div className="offline-badge">
<DownloadIcon /> Playing offline
</div>
</div>
);
};
PWA Manifest & Install Prompt
// useInstallPrompt.ts
export const useInstallPrompt = () => {
const [installPrompt, setInstallPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Check if already installed
if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
return;
}
const handleBeforeInstall = (e: BeforeInstallPromptEvent) => {
e.preventDefault();
setInstallPrompt(e);
};
const handleAppInstalled = () => {
setIsInstalled(true);
setInstallPrompt(null);
};
window.addEventListener("beforeinstallprompt", handleBeforeInstall);
window.addEventListener("appinstalled", handleAppInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstall);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}, []);
const promptInstall = async (): Promise<boolean> => {
if (!installPrompt) return false;
installPrompt.prompt();
const result = await installPrompt.userChoice;
if (result.outcome === "accepted") {
setInstallPrompt(null);
return true;
}
return false;
};
return { canInstall: !!installPrompt, isInstalled, promptInstall };
};
// InstallBanner.tsx
const InstallBanner = () => {
const { canInstall, promptInstall } = useInstallPrompt();
const [dismissed, setDismissed] = useState(false);
if (!canInstall || dismissed) return null;
return (
<div className="install-banner" role="complementary">
<div className="banner-content">
<AppIcon />
<div className="banner-text">
<h3>Install VideoStream</h3>
<p>Watch videos offline and get a native-like experience</p>
</div>
</div>
<div className="banner-actions">
<button onClick={promptInstall} className="install-button">
Install
</button>
<button
onClick={() => setDismissed(true)}
className="dismiss-button"
aria-label="Dismiss"
>
<CloseIcon />
</button>
</div>
</div>
);
};
Offline State Detection
// useOnlineStatus.ts
export const useOnlineStatus = () => {
const [isOnline, setIsOnline] = useState(
typeof navigator !== "undefined" ? navigator.onLine : true
);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
};
// OfflineIndicator.tsx
const OfflineIndicator = () => {
const isOnline = useOnlineStatus();
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
if (!isOnline) {
setShowBanner(true);
} else {
// Delay hiding to show reconnection message
const timer = setTimeout(() => setShowBanner(false), 3000);
return () => clearTimeout(timer);
}
}, [isOnline]);
if (!showBanner) return null;
return (
<div
className={`offline-indicator ${isOnline ? "reconnected" : "offline"}`}
role="status"
aria-live="polite"
>
{isOnline ? (
<>
<CheckIcon />
<span>Back online</span>
</>
) : (
<>
<OfflineIcon />
<span>You're offline. Downloaded videos are still available.</span>
</>
)}
</div>
);
};
PWA Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ PWA OFFLINE ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ User Interface │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ • Install Banner • Offline Indicator • Download Manager │ │
│ │ • Offline Player • Storage Info • Sync Status │ │
│ └──────────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼──────────────────────────────────────┐ │
│ │ Service Worker │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │ │
│ │ │ Cache Strategy │ │ Background │ │ Push Notifications │ │ │
│ │ │ │ │ Sync │ │ │ │ │
│ │ │ • Static: CF │ │ • Watch History│ │ • New videos │ │ │
│ │ │ • API: SWR │ │ • Preferences │ │ • Download complete │ │ │
│ │ │ • Video: NF+C │ │ • Analytics │ │ • Subscription updates │ │ │
│ │ └────────────────┘ └────────────────┘ └────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼──────────────────────────────────────┐ │
│ │ Storage Layer │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │ │
│ │ │ Cache API │ │ IndexedDB │ │ Local Storage │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ • HTML/CSS/JS │ │ • Video Blobs │ │ • User Preferences │ │ │
│ │ │ • API Responses│ │ • Metadata │ │ • Watch Position │ │ │
│ │ │ • Images │ │ • Sync Queue │ │ • Theme │ │ │
│ │ └────────────────┘ └────────────────┘ └────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ Cache Strategies: │
│ ───────────────── │
│ • CF (Cache First): Static assets, known to rarely change │
│ • SWR (Stale While Revalidate): API data, show cached, update in bg │
│ • NF+C (Network First + Cache): Video manifests, fresh content preferred │
│ │
│ Storage Limits: │
│ ─────────────── │
│ • Browser quota varies (Chrome: ~60% of disk, Safari: ~1GB) │
│ • Monitor with navigator.storage.estimate() │
│ • Implement LRU eviction for video segments │
│ • Expire downloads after 30 days (DRM requirement) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
18. Video Player Deep Dive
Custom Video Player Architecture
// VideoPlayer.tsx - Complete custom video player
interface VideoPlayerProps {
src: string;
poster?: string;
autoPlay?: boolean;
startTime?: number;
onProgress?: (time: number, duration: number) => void;
onEnded?: () => void;
onError?: (error: MediaError) => void;
}
interface PlayerState {
isPlaying: boolean;
isMuted: boolean;
isFullscreen: boolean;
isPiP: boolean;
currentTime: number;
duration: number;
buffered: number;
volume: number;
playbackRate: number;
quality: string;
isLoading: boolean;
isControlsVisible: boolean;
}
const VideoPlayer = ({
src,
poster,
autoPlay = false,
startTime = 0,
onProgress,
onEnded,
onError,
}: VideoPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
const [state, setState] = useState<PlayerState>({
isPlaying: false,
isMuted: false,
isFullscreen: false,
isPiP: false,
currentTime: 0,
duration: 0,
buffered: 0,
volume: 1,
playbackRate: 1,
quality: "auto",
isLoading: true,
isControlsVisible: true,
});
// Initialize video
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handlers = {
loadedmetadata: () => {
setState((prev) => ({ ...prev, duration: video.duration }));
if (startTime > 0) {
video.currentTime = startTime;
}
},
canplay: () => setState((prev) => ({ ...prev, isLoading: false })),
waiting: () => setState((prev) => ({ ...prev, isLoading: true })),
playing: () =>
setState((prev) => ({ ...prev, isPlaying: true, isLoading: false })),
pause: () => setState((prev) => ({ ...prev, isPlaying: false })),
ended: () => {
setState((prev) => ({ ...prev, isPlaying: false }));
onEnded?.();
},
timeupdate: () => {
setState((prev) => ({ ...prev, currentTime: video.currentTime }));
onProgress?.(video.currentTime, video.duration);
},
progress: () => {
if (video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
setState((prev) => ({ ...prev, buffered: bufferedEnd }));
}
},
volumechange: () => {
setState((prev) => ({
...prev,
volume: video.volume,
isMuted: video.muted,
}));
},
error: () => {
if (video.error) {
onError?.(video.error);
}
},
};
Object.entries(handlers).forEach(([event, handler]) => {
video.addEventListener(event, handler);
});
return () => {
Object.entries(handlers).forEach(([event, handler]) => {
video.removeEventListener(event, handler);
});
};
}, [startTime, onProgress, onEnded, onError]);
// Keyboard controls
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return;
const video = videoRef.current;
if (!video) return;
switch (e.key.toLowerCase()) {
case " ":
case "k":
e.preventDefault();
togglePlay();
break;
case "f":
e.preventDefault();
toggleFullscreen();
break;
case "m":
e.preventDefault();
toggleMute();
break;
case "arrowleft":
case "j":
e.preventDefault();
seek(video.currentTime - (e.shiftKey ? 5 : 10));
break;
case "arrowright":
case "l":
e.preventDefault();
seek(video.currentTime + (e.shiftKey ? 5 : 10));
break;
case "arrowup":
e.preventDefault();
setVolume(Math.min(1, video.volume + 0.1));
break;
case "arrowdown":
e.preventDefault();
setVolume(Math.max(0, video.volume - 0.1));
break;
case "home":
case "0":
e.preventDefault();
seek(0);
break;
case "end":
e.preventDefault();
seek(video.duration);
break;
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
case "8":
case "9":
e.preventDefault();
seek((parseInt(e.key) / 10) * video.duration);
break;
case ",":
if (e.shiftKey) {
e.preventDefault();
setPlaybackRate(Math.max(0.25, state.playbackRate - 0.25));
}
break;
case ".":
if (e.shiftKey) {
e.preventDefault();
setPlaybackRate(Math.min(2, state.playbackRate + 0.25));
}
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [state.playbackRate]);
// Auto-hide controls
useEffect(() => {
const showControls = () => {
setState((prev) => ({ ...prev, isControlsVisible: true }));
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
if (state.isPlaying) {
controlsTimeoutRef.current = setTimeout(() => {
setState((prev) => ({ ...prev, isControlsVisible: false }));
}, 3000);
}
};
const container = containerRef.current;
container?.addEventListener("mousemove", showControls);
container?.addEventListener("mouseleave", () => {
if (state.isPlaying) {
setState((prev) => ({ ...prev, isControlsVisible: false }));
}
});
return () => {
container?.removeEventListener("mousemove", showControls);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
}, [state.isPlaying]);
// Player actions
const togglePlay = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.paused ? video.play() : video.pause();
}, []);
const seek = useCallback((time: number) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Math.max(0, Math.min(time, video.duration));
}, []);
const setVolume = useCallback((volume: number) => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
if (volume > 0 && video.muted) {
video.muted = false;
}
}, []);
const toggleMute = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
}, []);
const toggleFullscreen = useCallback(async () => {
const container = containerRef.current;
if (!container) return;
if (document.fullscreenElement) {
await document.exitFullscreen();
setState((prev) => ({ ...prev, isFullscreen: false }));
} else {
await container.requestFullscreen();
setState((prev) => ({ ...prev, isFullscreen: true }));
}
}, []);
const togglePiP = useCallback(async () => {
const video = videoRef.current;
if (!video) return;
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
setState((prev) => ({ ...prev, isPiP: false }));
} else if (document.pictureInPictureEnabled) {
await video.requestPictureInPicture();
setState((prev) => ({ ...prev, isPiP: true }));
}
}, []);
const setPlaybackRate = useCallback((rate: number) => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = rate;
setState((prev) => ({ ...prev, playbackRate: rate }));
}, []);
const formatTime = (seconds: number): string => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return h > 0
? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
: `${m}:${s.toString().padStart(2, "0")}`;
};
return (
<div
ref={containerRef}
className={`video-player ${state.isFullscreen ? "fullscreen" : ""}`}
role="application"
aria-label="Video player"
>
<video
ref={videoRef}
src={src}
poster={poster}
autoPlay={autoPlay}
playsInline
onClick={togglePlay}
/>
{/* Loading spinner */}
{state.isLoading && (
<div className="loading-overlay" aria-hidden="true">
<Spinner />
</div>
)}
{/* Controls overlay */}
<div
className={`controls-overlay ${
state.isControlsVisible ? "visible" : ""
}`}
aria-hidden={!state.isControlsVisible}
>
{/* Progress bar */}
<div className="progress-container">
<div
className="buffered-bar"
style={{ width: `${(state.buffered / state.duration) * 100}%` }}
/>
<input
type="range"
className="progress-bar"
min={0}
max={state.duration || 100}
value={state.currentTime}
onChange={(e) => seek(parseFloat(e.target.value))}
aria-label="Video progress"
/>
</div>
{/* Controls bar */}
<div className="controls-bar">
<div className="left-controls">
<button
onClick={togglePlay}
aria-label={state.isPlaying ? "Pause" : "Play"}
>
{state.isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
<button
onClick={() => seek(state.currentTime - 10)}
aria-label="Rewind 10 seconds"
>
<RewindIcon />
</button>
<button
onClick={() => seek(state.currentTime + 10)}
aria-label="Forward 10 seconds"
>
<ForwardIcon />
</button>
<div className="volume-control">
<button
onClick={toggleMute}
aria-label={state.isMuted ? "Unmute" : "Mute"}
>
{state.isMuted || state.volume === 0 ? (
<MutedIcon />
) : (
<VolumeIcon />
)}
</button>
<input
type="range"
className="volume-slider"
min={0}
max={1}
step={0.1}
value={state.isMuted ? 0 : state.volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
aria-label="Volume"
/>
</div>
<span className="time-display">
{formatTime(state.currentTime)} / {formatTime(state.duration)}
</span>
</div>
<div className="right-controls">
<PlaybackRateMenu
currentRate={state.playbackRate}
onRateChange={setPlaybackRate}
/>
<button onClick={togglePiP} aria-label="Picture in Picture">
<PiPIcon />
</button>
<button
onClick={toggleFullscreen}
aria-label={state.isFullscreen ? "Exit fullscreen" : "Fullscreen"}
>
{state.isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</button>
</div>
</div>
</div>
{/* Screen reader announcements */}
<div className="sr-only" role="status" aria-live="polite">
{state.isPlaying ? "Playing" : "Paused"}
</div>
</div>
);
};
Buffering Strategy & Buffer Management
// BufferManager.ts - Intelligent buffer management
interface BufferConfig {
minBuffer: number; // Minimum buffer before playback (seconds)
maxBuffer: number; // Maximum buffer to maintain (seconds)
rebufferGoal: number; // Buffer goal after rebuffer event
lowLatencyMode: boolean;
}
class BufferManager {
private config: BufferConfig;
private hls: Hls | null = null;
private video: HTMLVideoElement;
private networkInfo: NetworkInformation | null = null;
constructor(video: HTMLVideoElement, config?: Partial<BufferConfig>) {
this.video = video;
this.config = {
minBuffer: 10,
maxBuffer: 30,
rebufferGoal: 5,
lowLatencyMode: false,
...config,
};
this.initNetworkMonitoring();
}
private initNetworkMonitoring(): void {
if ("connection" in navigator) {
this.networkInfo = (navigator as any).connection;
this.networkInfo?.addEventListener("change", () => {
this.adjustBufferForNetwork();
});
}
}
private adjustBufferForNetwork(): void {
if (!this.networkInfo || !this.hls) return;
const { effectiveType, downlink, saveData } = this.networkInfo;
// Adjust buffer based on network conditions
let bufferConfig: Partial<BufferConfig> = {};
if (saveData) {
// Data saver mode - minimize buffering
bufferConfig = { minBuffer: 5, maxBuffer: 15 };
} else if (effectiveType === "4g" && downlink > 10) {
// Fast connection - larger buffer
bufferConfig = { minBuffer: 15, maxBuffer: 60 };
} else if (effectiveType === "3g") {
// Moderate connection
bufferConfig = { minBuffer: 10, maxBuffer: 30 };
} else if (effectiveType === "2g" || effectiveType === "slow-2g") {
// Slow connection - aggressive buffering
bufferConfig = { minBuffer: 20, maxBuffer: 45 };
}
this.updateConfig(bufferConfig);
}
updateConfig(config: Partial<BufferConfig>): void {
this.config = { ...this.config, ...config };
if (this.hls) {
this.hls.config.maxBufferLength = this.config.maxBuffer;
this.hls.config.maxMaxBufferLength = this.config.maxBuffer * 2;
}
}
attachHls(hls: Hls): void {
this.hls = hls;
// Configure HLS.js buffer settings
hls.config.maxBufferLength = this.config.maxBuffer;
hls.config.maxMaxBufferLength = this.config.maxBuffer * 2;
hls.config.maxBufferHole = 0.5;
if (this.config.lowLatencyMode) {
hls.config.liveSyncDuration = 3;
hls.config.liveMaxLatencyDuration = 5;
hls.config.maxBufferLength = 8;
}
// Listen for buffer events
hls.on(Hls.Events.BUFFER_APPENDING, (_, data) => {
this.logBufferState("appending", data);
});
hls.on(Hls.Events.BUFFER_EOS, () => {
this.logBufferState("end-of-stream");
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.details === "bufferStalledError") {
this.handleBufferStall();
}
});
}
private handleBufferStall(): void {
console.warn("Buffer stalled, adjusting strategy");
// Temporarily lower quality to refill buffer
if (this.hls) {
const currentLevel = this.hls.currentLevel;
if (currentLevel > 0) {
this.hls.nextLevel = currentLevel - 1;
}
// Increase buffer goal temporarily
this.hls.config.maxBufferLength = this.config.rebufferGoal * 2;
// Reset after buffer is healthy
setTimeout(() => {
if (this.hls) {
this.hls.config.maxBufferLength = this.config.maxBuffer;
this.hls.nextLevel = -1; // Auto
}
}, 10000);
}
}
getBufferHealth(): { current: number; ahead: number; isHealthy: boolean } {
const video = this.video;
const currentTime = video.currentTime;
let bufferedAhead = 0;
for (let i = 0; i < video.buffered.length; i++) {
if (
video.buffered.start(i) <= currentTime &&
video.buffered.end(i) > currentTime
) {
bufferedAhead = video.buffered.end(i) - currentTime;
break;
}
}
return {
current: currentTime,
ahead: bufferedAhead,
isHealthy: bufferedAhead >= this.config.minBuffer,
};
}
private logBufferState(event: string, data?: any): void {
if (process.env.NODE_ENV === "development") {
const health = this.getBufferHealth();
console.log(`[Buffer] ${event}`, {
health,
config: this.config,
data,
});
}
}
}
Playback Speed & Trick Play
// TrickPlay.tsx - Advanced playback controls
const TrickPlay = ({
video,
onSeekStart,
onSeekEnd,
}: {
video: HTMLVideoElement;
onSeekStart?: () => void;
onSeekEnd?: () => void;
}) => {
const [isScrubbing, setIsScrubbing] = useState(false);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [thumbnailTime, setThumbnailTime] = useState(0);
const thumbnailCache = useRef<Map<number, string>>(new Map());
// Generate thumbnail sprite sheet URL
const getThumbnailUrl = useCallback(
(time: number): string => {
// Thumbnails are typically generated every 10 seconds
const interval = 10;
const index = Math.floor(time / interval);
const row = Math.floor(index / 10);
const col = index % 10;
return `/api/thumbnails/${video.dataset.videoId}?t=${
index * interval
}&row=${row}&col=${col}`;
},
[video.dataset.videoId]
);
// Preload nearby thumbnails
const preloadThumbnails = useCallback(
(centerTime: number) => {
const preloadRange = 30; // seconds
for (
let t = centerTime - preloadRange;
t <= centerTime + preloadRange;
t += 10
) {
if (t >= 0 && t <= video.duration && !thumbnailCache.current.has(t)) {
const img = new Image();
img.src = getThumbnailUrl(t);
thumbnailCache.current.set(t, img.src);
}
}
},
[video.duration, getThumbnailUrl]
);
const handleScrubStart = useCallback(() => {
setIsScrubbing(true);
onSeekStart?.();
video.pause();
}, [video, onSeekStart]);
const handleScrub = useCallback(
(time: number) => {
setThumbnailTime(time);
setThumbnailUrl(getThumbnailUrl(time));
preloadThumbnails(time);
},
[getThumbnailUrl, preloadThumbnails]
);
const handleScrubEnd = useCallback(
(time: number) => {
setIsScrubbing(false);
video.currentTime = time;
video.play();
onSeekEnd?.();
},
[video, onSeekEnd]
);
return (
<div className="trick-play">
{isScrubbing && thumbnailUrl && (
<div
className="thumbnail-preview"
style={{
left: `${(thumbnailTime / video.duration) * 100}%`,
}}
>
<img
src={thumbnailUrl}
alt={`Preview at ${formatTime(thumbnailTime)}`}
/>
<span className="preview-time">{formatTime(thumbnailTime)}</span>
</div>
)}
<SeekBar
duration={video.duration}
currentTime={video.currentTime}
onScrubStart={handleScrubStart}
onScrub={handleScrub}
onScrubEnd={handleScrubEnd}
/>
</div>
);
};
// Playback speed controls
const PlaybackRateMenu = ({
currentRate,
onRateChange,
}: {
currentRate: number;
onRateChange: (rate: number) => void;
}) => {
const [isOpen, setIsOpen] = useState(false);
const rates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
return (
<div className="playback-rate-menu">
<button
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="menu"
>
{currentRate}x
</button>
{isOpen && (
<ul role="menu" className="rate-options">
{rates.map((rate) => (
<li key={rate}>
<button
role="menuitem"
onClick={() => {
onRateChange(rate);
setIsOpen(false);
}}
aria-current={rate === currentRate}
>
{rate === 1 ? "Normal" : `${rate}x`}
</button>
</li>
))}
</ul>
)}
</div>
);
};
Video Player State Management
// useVideoPlayer.ts - Comprehensive video player hook
import { useReducer, useCallback, useRef, useEffect } from "react";
type VideoState = {
status:
| "idle"
| "loading"
| "ready"
| "playing"
| "paused"
| "buffering"
| "ended"
| "error";
currentTime: number;
duration: number;
buffered: TimeRanges | null;
volume: number;
muted: boolean;
playbackRate: number;
quality: number; // -1 for auto
availableQualities: QualityLevel[];
captions: TextTrack | null;
availableCaptions: TextTrack[];
isFullscreen: boolean;
isPictureInPicture: boolean;
error: MediaError | null;
};
type VideoAction =
| { type: "LOAD" }
| { type: "LOADED"; duration: number }
| { type: "PLAY" }
| { type: "PAUSE" }
| { type: "BUFFERING" }
| { type: "TIME_UPDATE"; currentTime: number }
| { type: "BUFFER_UPDATE"; buffered: TimeRanges }
| { type: "VOLUME_CHANGE"; volume: number; muted: boolean }
| { type: "RATE_CHANGE"; playbackRate: number }
| { type: "QUALITY_CHANGE"; quality: number }
| { type: "QUALITIES_AVAILABLE"; qualities: QualityLevel[] }
| { type: "CAPTION_CHANGE"; captions: TextTrack | null }
| { type: "CAPTIONS_AVAILABLE"; captions: TextTrack[] }
| { type: "FULLSCREEN_CHANGE"; isFullscreen: boolean }
| { type: "PIP_CHANGE"; isPictureInPicture: boolean }
| { type: "ENDED" }
| { type: "ERROR"; error: MediaError };
const initialState: VideoState = {
status: "idle",
currentTime: 0,
duration: 0,
buffered: null,
volume: 1,
muted: false,
playbackRate: 1,
quality: -1,
availableQualities: [],
captions: null,
availableCaptions: [],
isFullscreen: false,
isPictureInPicture: false,
error: null,
};
function videoReducer(state: VideoState, action: VideoAction): VideoState {
switch (action.type) {
case "LOAD":
return { ...state, status: "loading", error: null };
case "LOADED":
return { ...state, status: "ready", duration: action.duration };
case "PLAY":
return { ...state, status: "playing" };
case "PAUSE":
return { ...state, status: "paused" };
case "BUFFERING":
return { ...state, status: "buffering" };
case "TIME_UPDATE":
return { ...state, currentTime: action.currentTime };
case "BUFFER_UPDATE":
return { ...state, buffered: action.buffered };
case "VOLUME_CHANGE":
return { ...state, volume: action.volume, muted: action.muted };
case "RATE_CHANGE":
return { ...state, playbackRate: action.playbackRate };
case "QUALITY_CHANGE":
return { ...state, quality: action.quality };
case "QUALITIES_AVAILABLE":
return { ...state, availableQualities: action.qualities };
case "CAPTION_CHANGE":
return { ...state, captions: action.captions };
case "CAPTIONS_AVAILABLE":
return { ...state, availableCaptions: action.captions };
case "FULLSCREEN_CHANGE":
return { ...state, isFullscreen: action.isFullscreen };
case "PIP_CHANGE":
return { ...state, isPictureInPicture: action.isPictureInPicture };
case "ENDED":
return { ...state, status: "ended" };
case "ERROR":
return { ...state, status: "error", error: action.error };
default:
return state;
}
}
export function useVideoPlayer(videoRef: RefObject<HTMLVideoElement>) {
const [state, dispatch] = useReducer(videoReducer, initialState);
// Bind video events to dispatch
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const events: Record<string, () => void> = {
loadstart: () => dispatch({ type: "LOAD" }),
loadedmetadata: () =>
dispatch({ type: "LOADED", duration: video.duration }),
play: () => dispatch({ type: "PLAY" }),
pause: () => dispatch({ type: "PAUSE" }),
waiting: () => dispatch({ type: "BUFFERING" }),
playing: () => dispatch({ type: "PLAY" }),
timeupdate: () =>
dispatch({ type: "TIME_UPDATE", currentTime: video.currentTime }),
progress: () =>
dispatch({ type: "BUFFER_UPDATE", buffered: video.buffered }),
volumechange: () =>
dispatch({
type: "VOLUME_CHANGE",
volume: video.volume,
muted: video.muted,
}),
ratechange: () =>
dispatch({ type: "RATE_CHANGE", playbackRate: video.playbackRate }),
ended: () => dispatch({ type: "ENDED" }),
error: () =>
video.error && dispatch({ type: "ERROR", error: video.error }),
};
Object.entries(events).forEach(([event, handler]) => {
video.addEventListener(event, handler);
});
return () => {
Object.entries(events).forEach(([event, handler]) => {
video.removeEventListener(event, handler);
});
};
}, [videoRef]);
// Actions
const actions = {
play: useCallback(() => videoRef.current?.play(), [videoRef]),
pause: useCallback(() => videoRef.current?.pause(), [videoRef]),
seek: useCallback(
(time: number) => {
if (videoRef.current) videoRef.current.currentTime = time;
},
[videoRef]
),
setVolume: useCallback(
(volume: number) => {
if (videoRef.current) videoRef.current.volume = volume;
},
[videoRef]
),
setMuted: useCallback(
(muted: boolean) => {
if (videoRef.current) videoRef.current.muted = muted;
},
[videoRef]
),
setPlaybackRate: useCallback(
(rate: number) => {
if (videoRef.current) videoRef.current.playbackRate = rate;
},
[videoRef]
),
};
return { state, actions, dispatch };
}
Video Player Component Hierarchy
┌─────────────────────────────────────────────────────────────────────────────┐
│ VIDEO PLAYER COMPONENT HIERARCHY │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ VideoPlayerProvider (Context) │ │
│ │ └── State: currentVideo, playlist, preferences │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ VideoPlayer (Container) │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ VideoCanvas │ │ │ │
│ │ │ │ └── <video> element │ │ │ │
│ │ │ │ └── LoadingOverlay │ │ │ │
│ │ │ │ └── ErrorOverlay │ │ │ │
│ │ │ └─────────────────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ ControlsOverlay │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ ProgressBar │ │ │ │ │
│ │ │ │ │ └── BufferedProgress │ │ │ │ │
│ │ │ │ │ └── PlayedProgress │ │ │ │ │
│ │ │ │ │ └── SeekHandle │ │ │ │ │
│ │ │ │ │ └── ThumbnailPreview │ │ │ │ │
│ │ │ │ │ └── ChapterMarkers │ │ │ │ │
│ │ │ │ └──────────────────────────────────────────────────┘ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ ControlsBar │ │ │ │ │
│ │ │ │ │ ├── PlayPauseButton │ │ │ │ │
│ │ │ │ │ ├── SeekButtons (-10s, +10s) │ │ │ │ │
│ │ │ │ │ ├── VolumeControl │ │ │ │ │
│ │ │ │ │ ├── TimeDisplay │ │ │ │ │
│ │ │ │ │ ├── PlaybackRateMenu │ │ │ │ │
│ │ │ │ │ ├── QualitySelector │ │ │ │ │
│ │ │ │ │ ├── CaptionsMenu │ │ │ │ │
│ │ │ │ │ ├── PiPButton │ │ │ │ │
│ │ │ │ │ └── FullscreenButton │ │ │ │ │
│ │ │ │ └──────────────────────────────────────────────────┘ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ └─────────────────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ CaptionDisplay │ │ │ │
│ │ │ │ └── Renders active caption cues │ │ │ │
│ │ │ └─────────────────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
19. Internationalization (i18n)
i18n Architecture
// i18n/config.ts - i18n configuration
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import Backend from "i18next-http-backend";
// Supported locales with metadata
export const SUPPORTED_LOCALES = {
"en-US": { name: "English (US)", dir: "ltr", dateFormat: "MM/DD/YYYY" },
"en-GB": { name: "English (UK)", dir: "ltr", dateFormat: "DD/MM/YYYY" },
es: { name: "Español", dir: "ltr", dateFormat: "DD/MM/YYYY" },
"pt-BR": { name: "Português (Brasil)", dir: "ltr", dateFormat: "DD/MM/YYYY" },
fr: { name: "Français", dir: "ltr", dateFormat: "DD/MM/YYYY" },
de: { name: "Deutsch", dir: "ltr", dateFormat: "DD.MM.YYYY" },
ja: { name: "日本語", dir: "ltr", dateFormat: "YYYY/MM/DD" },
ko: { name: "한국어", dir: "ltr", dateFormat: "YYYY.MM.DD" },
"zh-CN": { name: "简体中文", dir: "ltr", dateFormat: "YYYY-MM-DD" },
"zh-TW": { name: "繁體中文", dir: "ltr", dateFormat: "YYYY/MM/DD" },
ar: { name: "العربية", dir: "rtl", dateFormat: "DD/MM/YYYY" },
he: { name: "עברית", dir: "rtl", dateFormat: "DD/MM/YYYY" },
hi: { name: "हिन्दी", dir: "ltr", dateFormat: "DD/MM/YYYY" },
th: { name: "ไทย", dir: "ltr", dateFormat: "DD/MM/YYYY" },
vi: { name: "Tiếng Việt", dir: "ltr", dateFormat: "DD/MM/YYYY" },
ru: { name: "Русский", dir: "ltr", dateFormat: "DD.MM.YYYY" },
} as const;
export type SupportedLocale = keyof typeof SUPPORTED_LOCALES;
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en-US",
supportedLngs: Object.keys(SUPPORTED_LOCALES),
debug: process.env.NODE_ENV === "development",
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
detection: {
order: ["querystring", "cookie", "localStorage", "navigator"],
caches: ["localStorage", "cookie"],
cookieMinutes: 43200, // 30 days
},
interpolation: {
escapeValue: false, // React already escapes
format: (value, format, lng) => {
if (format === "number") {
return new Intl.NumberFormat(lng).format(value);
}
if (format === "currency") {
return new Intl.NumberFormat(lng, {
style: "currency",
currency: "USD",
}).format(value);
}
return value;
},
},
ns: ["common", "player", "search", "upload", "settings"],
defaultNS: "common",
react: {
useSuspense: true,
bindI18n: "languageChanged loaded",
},
});
export default i18n;
Translation Files Structure
// locales/en-US/player.json
{
"controls": {
"play": "Play",
"pause": "Pause",
"mute": "Mute",
"unmute": "Unmute",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit fullscreen",
"pictureInPicture": "Picture in picture",
"settings": "Settings",
"captions": "Subtitles/CC",
"quality": "Quality",
"playbackSpeed": "Playback speed",
"rewind": "Rewind {{seconds}} seconds",
"forward": "Forward {{seconds}} seconds"
},
"quality": {
"auto": "Auto",
"hd": "HD",
"uhd": "4K"
},
"speed": {
"normal": "Normal"
},
"captions": {
"off": "Off",
"autoGenerated": "Auto-generated"
},
"errors": {
"playback": "An error occurred during playback",
"network": "Network error. Check your connection.",
"format": "This video format is not supported"
},
"status": {
"buffering": "Buffering...",
"loading": "Loading video..."
},
"time": {
"remaining": "{{time}} remaining",
"duration": "{{current}} / {{total}}"
}
}
// locales/ja/player.json
{
"controls": {
"play": "再生",
"pause": "一時停止",
"mute": "ミュート",
"unmute": "ミュート解除",
"fullscreen": "全画面",
"exitFullscreen": "全画面を終了",
"pictureInPicture": "ピクチャーインピクチャー",
"settings": "設定",
"captions": "字幕",
"quality": "画質",
"playbackSpeed": "再生速度",
"rewind": "{{seconds}}秒戻る",
"forward": "{{seconds}}秒進む"
},
"quality": {
"auto": "自動",
"hd": "HD",
"uhd": "4K"
},
"speed": {
"normal": "標準"
},
"captions": {
"off": "オフ",
"autoGenerated": "自動生成"
},
"errors": {
"playback": "再生中にエラーが発生しました",
"network": "ネットワークエラー。接続を確認してください。",
"format": "この動画形式はサポートされていません"
},
"status": {
"buffering": "バッファリング中...",
"loading": "動画を読み込んでいます..."
},
"time": {
"remaining": "残り{{time}}",
"duration": "{{current}} / {{total}}"
}
}
// locales/ar/player.json (RTL)
{
"controls": {
"play": "تشغيل",
"pause": "إيقاف مؤقت",
"mute": "كتم الصوت",
"unmute": "إلغاء كتم الصوت",
"fullscreen": "ملء الشاشة",
"exitFullscreen": "الخروج من ملء الشاشة",
"pictureInPicture": "صورة داخل صورة",
"settings": "الإعدادات",
"captions": "الترجمة",
"quality": "الجودة",
"playbackSpeed": "سرعة التشغيل",
"rewind": "ترجيع {{seconds}} ثانية",
"forward": "تقديم {{seconds}} ثانية"
}
}
RTL (Right-to-Left) Support
// hooks/useDirection.ts
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LOCALES, SupportedLocale } from '@/i18n/config';
export const useDirection = () => {
const { i18n } = useTranslation();
const locale = i18n.language as SupportedLocale;
const config = SUPPORTED_LOCALES[locale] || SUPPORTED_LOCALES['en-US'];
return {
dir: config.dir,
isRTL: config.dir === 'rtl',
locale,
};
};
// components/DirectionProvider.tsx
import { useDirection } from '@/hooks/useDirection';
import { useEffect } from 'react';
export const DirectionProvider = ({ children }: { children: React.ReactNode }) => {
const { dir, isRTL } = useDirection();
useEffect(() => {
document.documentElement.dir = dir;
document.documentElement.lang = i18n.language;
// Add RTL class for CSS targeting
if (isRTL) {
document.documentElement.classList.add('rtl');
} else {
document.documentElement.classList.remove('rtl');
}
}, [dir, isRTL]);
return <>{children}</>;
};
// styles/rtl.css
/* RTL-specific styles */
.rtl .video-controls {
flex-direction: row-reverse;
}
.rtl .progress-bar {
direction: rtl;
}
.rtl .time-display {
direction: ltr; /* Keep numbers LTR */
unicode-bidi: embed;
}
.rtl .seek-forward {
transform: scaleX(-1); /* Flip seek icons */
}
.rtl .seek-backward {
transform: scaleX(-1);
}
.rtl .volume-slider {
direction: rtl;
}
/* Logical properties for RTL support */
.video-sidebar {
margin-inline-start: 16px;
padding-inline-end: 12px;
border-inline-start: 1px solid var(--border-color);
}
.comment-reply {
margin-inline-start: 40px;
}
Number & Date Formatting
// utils/formatters.ts
import { useTranslation } from "react-i18next";
// View count formatting (1.2M, 5.4K, etc.)
export const useViewCountFormatter = () => {
const { i18n } = useTranslation();
return (count: number): string => {
const locale = i18n.language;
if (count >= 1_000_000_000) {
return new Intl.NumberFormat(locale, {
notation: "compact",
maximumFractionDigits: 1,
}).format(count);
}
if (count >= 1_000_000) {
return new Intl.NumberFormat(locale, {
notation: "compact",
maximumFractionDigits: 1,
}).format(count);
}
if (count >= 1_000) {
return new Intl.NumberFormat(locale, {
notation: "compact",
maximumFractionDigits: 1,
}).format(count);
}
return new Intl.NumberFormat(locale).format(count);
};
};
// Relative time formatting (2 hours ago, 3 days ago)
export const useRelativeTimeFormatter = () => {
const { i18n } = useTranslation();
return (date: Date | string): string => {
const locale = i18n.language;
const now = new Date();
const targetDate = new Date(date);
const diffMs = now.getTime() - targetDate.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (diffSeconds < 60) {
return rtf.format(-diffSeconds, "seconds");
}
const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) {
return rtf.format(-diffMinutes, "minutes");
}
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
return rtf.format(-diffHours, "hours");
}
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) {
return rtf.format(-diffDays, "days");
}
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 4) {
return rtf.format(-diffWeeks, "weeks");
}
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) {
return rtf.format(-diffMonths, "months");
}
const diffYears = Math.floor(diffDays / 365);
return rtf.format(-diffYears, "years");
};
};
// Duration formatting (1:23:45)
export const useDurationFormatter = () => {
const { i18n } = useTranslation();
return (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
// Use locale-aware number formatting for parts
const locale = i18n.language;
const pad = (n: number) => n.toString().padStart(2, "0");
if (hours > 0) {
return `${hours}:${pad(minutes)}:${pad(secs)}`;
}
return `${minutes}:${pad(secs)}`;
};
};
// File size formatting
export const useFileSizeFormatter = () => {
const { i18n, t } = useTranslation();
return (bytes: number): string => {
const locale = i18n.language;
const units = ["B", "KB", "MB", "GB", "TB"];
let unitIndex = 0;
let size = bytes;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return (
new Intl.NumberFormat(locale, {
maximumFractionDigits: 1,
}).format(size) +
" " +
units[unitIndex]
);
};
};
Language Selector Component
// LanguageSelector.tsx
import { useTranslation } from "react-i18next";
import { SUPPORTED_LOCALES, SupportedLocale } from "@/i18n/config";
const LanguageSelector = () => {
const { i18n, t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const currentLocale = i18n.language as SupportedLocale;
const currentConfig = SUPPORTED_LOCALES[currentLocale];
const handleLanguageChange = async (locale: SupportedLocale) => {
await i18n.changeLanguage(locale);
setIsOpen(false);
// Persist preference
localStorage.setItem("preferred-language", locale);
// Update API calls to include language header
document.cookie = `locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365}`;
};
return (
<div className="language-selector">
<button
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={t("settings.changeLanguage")}
>
<GlobeIcon />
<span>{currentConfig?.name || "English"}</span>
<ChevronIcon className={isOpen ? "rotated" : ""} />
</button>
{isOpen && (
<ul
role="listbox"
aria-label={t("settings.selectLanguage")}
className="language-dropdown"
>
{Object.entries(SUPPORTED_LOCALES).map(([code, config]) => (
<li key={code}>
<button
role="option"
aria-selected={code === currentLocale}
onClick={() => handleLanguageChange(code as SupportedLocale)}
className={code === currentLocale ? "selected" : ""}
>
<span className="language-name">{config.name}</span>
{code === currentLocale && <CheckIcon />}
</button>
</li>
))}
</ul>
)}
</div>
);
};
Pluralization & Complex Translations
// locales/en-US/common.json
{
"views": {
"count_one": "{{count}} view",
"count_other": "{{count}} views"
},
"subscribers": {
"count_one": "{{count}} subscriber",
"count_other": "{{count}} subscribers"
},
"comments": {
"count_zero": "No comments yet",
"count_one": "{{count}} comment",
"count_other": "{{count}} comments"
},
"likes": {
"count_one": "{{count}} like",
"count_other": "{{count}} likes"
},
"uploadedBy": "Uploaded by <1>{{channel}}</1>",
"watchLater": "Watch later",
"shareVideo": "Share video",
"reportVideo": "Report video"
}
// locales/ru/common.json (Russian has complex pluralization)
{
"views": {
"count_one": "{{count}} просмотр",
"count_few": "{{count}} просмотра",
"count_many": "{{count}} просмотров",
"count_other": "{{count}} просмотров"
},
"comments": {
"count_zero": "Комментариев пока нет",
"count_one": "{{count}} комментарий",
"count_few": "{{count}} комментария",
"count_many": "{{count}} комментариев",
"count_other": "{{count}} комментариев"
}
}
// locales/ar/common.json (Arabic has even more complex pluralization)
{
"views": {
"count_zero": "لا مشاهدات",
"count_one": "مشاهدة واحدة",
"count_two": "مشاهدتان",
"count_few": "{{count}} مشاهدات",
"count_many": "{{count}} مشاهدة",
"count_other": "{{count}} مشاهدة"
}
}
// Usage in component
const VideoStats = ({ views, likes, comments }) => {
const { t } = useTranslation();
const formatViews = useViewCountFormatter();
return (
<div className="video-stats">
<span>{t('views.count', { count: views })}</span>
<span>{t('likes.count', { count: likes })}</span>
<span>{t('comments.count', { count: comments })}</span>
</div>
);
};
i18n Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ INTERNATIONALIZATION ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Language Detection Flow: │
│ ──────────────────────── │
│ │
│ 1. URL Parameter (?lang=ja) │
│ │ │
│ ▼ │
│ 2. Cookie (locale=ja) │
│ │ │
│ ▼ │
│ 3. localStorage (preferred-language) │
│ │ │
│ ▼ │
│ 4. Browser navigator.language │
│ │ │
│ ▼ │
│ 5. Fallback to en-US │
│ │
│ Translation Loading Strategy: │
│ ───────────────────────────── │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │
│ │ Critical Path │ │ Lazy Load │ │ On-Demand │ │
│ │ │ │ │ │ │ │
│ │ • common.json │ │ • player.json │ │ • upload.json │ │
│ │ • navigation │ │ • search.json │ │ • studio.json │ │
│ │ │ │ • settings.json│ │ • analytics.json │ │
│ └────────────────┘ └────────────────┘ └────────────────────────┘ │
│ │ │ │ │
│ Bundled Route-based Feature-based │
│ │
│ RTL Support: │
│ ──────────── │
│ • Arabic (ar) │
│ • Hebrew (he) │
│ • Persian (fa) │
│ • Urdu (ur) │
│ │
│ Implementation: │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ <html dir="rtl" lang="ar"> │ │
│ │ <body class="rtl"> │ │
│ │ CSS: margin-inline-start instead of margin-left │ │
│ │ CSS: logical properties (start/end vs left/right) │ │
│ │ Icons: mirror directional icons │ │
│ │ </body> │ │
│ │ </html> │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ Number Formats by Locale: │
│ ───────────────────────── │
│ │ Locale │ Number │ Currency │ Percentage │ │
│ │────────│─────────────│─────────────│────────────│ │
│ │ en-US │ 1,234.56 │ $1,234.56 │ 12.34% │ │
│ │ de-DE │ 1.234,56 │ 1.234,56 € │ 12,34 % │ │
│ │ fr-FR │ 1 234,56 │ 1 234,56 € │ 12,34 % │ │
│ │ ja-JP │ 1,234.56 │ ¥1,234 │ 12.34% │ │
│ │ ar-SA │ ١٬٢٣٤٫٥٦ │ ١٬٢٣٤٫٥٦ ر.س│ ١٢٫٣٤٪ │ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
20. Analytics & Monitoring
Video Analytics Tracking
// analytics/VideoAnalytics.ts
interface VideoEvent {
type:
| "play"
| "pause"
| "seek"
| "ended"
| "quality_change"
| "buffer"
| "error";
videoId: string;
timestamp: number;
data?: Record<string, any>;
}
interface WatchSession {
sessionId: string;
videoId: string;
userId?: string;
startTime: number;
watchDuration: number;
completionRate: number;
qualityChanges: number;
bufferingEvents: number;
seekEvents: number;
deviceType: string;
browser: string;
country?: string;
}
class VideoAnalytics {
private sessionId: string;
private videoId: string;
private startTime: number = 0;
private totalWatchTime: number = 0;
private lastUpdateTime: number = 0;
private isPlaying: boolean = false;
private eventQueue: VideoEvent[] = [];
private flushInterval: number = 10000; // 10 seconds
constructor(videoId: string) {
this.sessionId = crypto.randomUUID();
this.videoId = videoId;
this.startPeriodicFlush();
}
// Track play event
trackPlay(currentTime: number): void {
this.isPlaying = true;
this.lastUpdateTime = Date.now();
this.queueEvent({
type: "play",
videoId: this.videoId,
timestamp: Date.now(),
data: { currentTime },
});
}
// Track pause event
trackPause(currentTime: number): void {
this.updateWatchTime();
this.isPlaying = false;
this.queueEvent({
type: "pause",
videoId: this.videoId,
timestamp: Date.now(),
data: { currentTime },
});
}
// Track seek event
trackSeek(fromTime: number, toTime: number): void {
this.queueEvent({
type: "seek",
videoId: this.videoId,
timestamp: Date.now(),
data: { fromTime, toTime, seekDistance: toTime - fromTime },
});
}
// Track buffering event
trackBuffering(currentTime: number, bufferDuration?: number): void {
this.queueEvent({
type: "buffer",
videoId: this.videoId,
timestamp: Date.now(),
data: { currentTime, bufferDuration },
});
}
// Track quality change
trackQualityChange(fromQuality: string, toQuality: string): void {
this.queueEvent({
type: "quality_change",
videoId: this.videoId,
timestamp: Date.now(),
data: { fromQuality, toQuality },
});
}
// Track video ended
trackEnded(watchPercentage: number): void {
this.updateWatchTime();
this.queueEvent({
type: "ended",
videoId: this.videoId,
timestamp: Date.now(),
data: {
totalWatchTime: this.totalWatchTime,
watchPercentage,
completed: watchPercentage >= 90,
},
});
this.flush(); // Immediate flush on video end
}
// Track error
trackError(errorCode: number, errorMessage: string): void {
this.queueEvent({
type: "error",
videoId: this.videoId,
timestamp: Date.now(),
data: { errorCode, errorMessage },
});
this.flush(); // Immediate flush on error
}
private updateWatchTime(): void {
if (this.isPlaying && this.lastUpdateTime > 0) {
this.totalWatchTime += (Date.now() - this.lastUpdateTime) / 1000;
this.lastUpdateTime = Date.now();
}
}
private queueEvent(event: VideoEvent): void {
this.eventQueue.push(event);
// Flush if queue is getting large
if (this.eventQueue.length >= 20) {
this.flush();
}
}
private startPeriodicFlush(): void {
setInterval(() => {
this.updateWatchTime();
this.flush();
}, this.flushInterval);
}
private async flush(): Promise<void> {
if (this.eventQueue.length === 0) return;
const events = [...this.eventQueue];
this.eventQueue = [];
try {
await fetch("/api/v1/analytics/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: this.sessionId,
events,
metadata: {
userAgent: navigator.userAgent,
screenSize: `${screen.width}x${screen.height}`,
connectionType: (navigator as any).connection?.effectiveType,
},
}),
keepalive: true, // Ensure request completes even on page unload
});
} catch (error) {
// Re-queue events on failure
this.eventQueue = [...events, ...this.eventQueue];
}
}
// Clean up on unmount
destroy(): void {
this.trackPause(0);
this.flush();
}
}
Performance Monitoring
// monitoring/PerformanceMonitor.ts
interface PerformanceMetrics {
// Video metrics
timeToFirstFrame: number;
initialBufferTime: number;
rebufferingRatio: number;
averageBitrate: number;
qualitySwitches: number;
// Page metrics
fcp: number; // First Contentful Paint
lcp: number; // Largest Contentful Paint
fid: number; // First Input Delay
cls: number; // Cumulative Layout Shift
ttfb: number; // Time to First Byte
// Custom metrics
videoLoadTime: number;
thumbnailLoadTime: number;
apiLatency: Record<string, number>;
}
class PerformanceMonitor {
private metrics: Partial<PerformanceMetrics> = {};
private observer: PerformanceObserver | null = null;
constructor() {
this.initWebVitals();
this.initVideoMetrics();
}
private initWebVitals(): void {
// Observe Core Web Vitals
if ("PerformanceObserver" in window) {
// LCP
this.observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.metrics.lcp = lastEntry.startTime;
});
this.observer.observe({ entryTypes: ["largest-contentful-paint"] });
// FID
new PerformanceObserver((list) => {
const entries = list.getEntries();
this.metrics.fid = entries[0].processingStart - entries[0].startTime;
}).observe({ entryTypes: ["first-input"] });
// CLS
let clsValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
this.metrics.cls = clsValue;
}).observe({ entryTypes: ["layout-shift"] });
}
// FCP
const paintEntries = performance.getEntriesByType("paint");
const fcp = paintEntries.find((e) => e.name === "first-contentful-paint");
if (fcp) {
this.metrics.fcp = fcp.startTime;
}
// TTFB
const navEntry = performance.getEntriesByType("navigation")[0] as any;
if (navEntry) {
this.metrics.ttfb = navEntry.responseStart - navEntry.requestStart;
}
}
private initVideoMetrics(): void {
// Will be populated by video player
this.metrics.timeToFirstFrame = 0;
this.metrics.initialBufferTime = 0;
this.metrics.rebufferingRatio = 0;
}
// Called by video player
recordTimeToFirstFrame(time: number): void {
this.metrics.timeToFirstFrame = time;
}
recordInitialBufferTime(time: number): void {
this.metrics.initialBufferTime = time;
}
recordRebufferingEvent(duration: number, totalPlayTime: number): void {
this.metrics.rebufferingRatio = duration / totalPlayTime;
}
// Track API latency
recordApiLatency(endpoint: string, duration: number): void {
if (!this.metrics.apiLatency) {
this.metrics.apiLatency = {};
}
this.metrics.apiLatency[endpoint] = duration;
}
// Get all metrics
getMetrics(): Partial<PerformanceMetrics> {
return { ...this.metrics };
}
// Report metrics to backend
async reportMetrics(): Promise<void> {
await fetch("/api/v1/metrics/performance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
metrics: this.metrics,
url: window.location.href,
timestamp: Date.now(),
}),
});
}
}
// React hook for performance monitoring
export const usePerformanceMonitor = () => {
const monitorRef = useRef<PerformanceMonitor | null>(null);
useEffect(() => {
monitorRef.current = new PerformanceMonitor();
// Report on page unload
const handleUnload = () => {
monitorRef.current?.reportMetrics();
};
window.addEventListener("beforeunload", handleUnload);
return () => {
window.removeEventListener("beforeunload", handleUnload);
};
}, []);
return monitorRef.current;
};
Error Tracking
// monitoring/ErrorTracker.ts
interface ErrorReport {
id: string;
type: "js_error" | "video_error" | "network_error" | "api_error";
message: string;
stack?: string;
context: {
url: string;
userAgent: string;
timestamp: number;
videoId?: string;
userId?: string;
};
metadata?: Record<string, any>;
}
class ErrorTracker {
private static instance: ErrorTracker;
private errorQueue: ErrorReport[] = [];
private constructor() {
this.setupGlobalHandlers();
}
static getInstance(): ErrorTracker {
if (!ErrorTracker.instance) {
ErrorTracker.instance = new ErrorTracker();
}
return ErrorTracker.instance;
}
private setupGlobalHandlers(): void {
// Catch unhandled errors
window.onerror = (message, source, lineno, colno, error) => {
this.captureError({
type: "js_error",
message: String(message),
stack: error?.stack,
metadata: { source, lineno, colno },
});
};
// Catch unhandled promise rejections
window.onunhandledrejection = (event) => {
this.captureError({
type: "js_error",
message: event.reason?.message || "Unhandled Promise Rejection",
stack: event.reason?.stack,
});
};
// Network errors via fetch wrapper
this.wrapFetch();
}
private wrapFetch(): void {
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const startTime = performance.now();
const url = args[0] instanceof Request ? args[0].url : String(args[0]);
try {
const response = await originalFetch(...args);
if (!response.ok && response.status >= 500) {
this.captureError({
type: "api_error",
message: `API Error: ${response.status} ${response.statusText}`,
metadata: {
url,
status: response.status,
duration: performance.now() - startTime,
},
});
}
return response;
} catch (error) {
this.captureError({
type: "network_error",
message:
error instanceof Error ? error.message : "Network request failed",
metadata: { url, duration: performance.now() - startTime },
});
throw error;
}
};
}
captureError(error: Omit<ErrorReport, "id" | "context">): void {
const report: ErrorReport = {
id: crypto.randomUUID(),
...error,
context: {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
},
};
this.errorQueue.push(report);
// Immediate report for critical errors
if (error.type === "video_error") {
this.flush();
} else if (this.errorQueue.length >= 10) {
this.flush();
}
}
captureVideoError(
videoId: string,
errorCode: number,
errorMessage: string,
metadata?: Record<string, any>
): void {
this.captureError({
type: "video_error",
message: `Video Error [${errorCode}]: ${errorMessage}`,
metadata: { videoId, errorCode, ...metadata },
});
}
private async flush(): Promise<void> {
if (this.errorQueue.length === 0) return;
const errors = [...this.errorQueue];
this.errorQueue = [];
try {
await fetch("/api/v1/errors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ errors }),
keepalive: true,
});
} catch {
// Re-queue on failure
this.errorQueue = [...errors, ...this.errorQueue];
}
}
}
// React Error Boundary with tracking
export class TrackedErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
ErrorTracker.getInstance().captureError({
type: "js_error",
message: error.message,
stack: error.stack,
metadata: { componentStack: errorInfo.componentStack },
});
}
render(): React.ReactNode {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
Analytics Dashboard Components
// components/AnalyticsDashboard.tsx
const VideoAnalyticsDashboard = ({ videoId }: { videoId: string }) => {
const { data: analytics, isLoading } = useQuery(
["video-analytics", videoId],
() => fetchVideoAnalytics(videoId)
);
if (isLoading) return <Skeleton />;
return (
<div className="analytics-dashboard">
{/* Overview Cards */}
<div className="overview-grid">
<MetricCard
title="Total Views"
value={formatNumber(analytics.totalViews)}
change={analytics.viewsChange}
icon={<ViewsIcon />}
/>
<MetricCard
title="Watch Time"
value={formatDuration(analytics.totalWatchTime)}
change={analytics.watchTimeChange}
icon={<ClockIcon />}
/>
<MetricCard
title="Avg. View Duration"
value={formatDuration(analytics.avgViewDuration)}
change={analytics.avgDurationChange}
icon={<TimerIcon />}
/>
<MetricCard
title="Completion Rate"
value={`${analytics.completionRate}%`}
change={analytics.completionChange}
icon={<CheckCircleIcon />}
/>
</div>
{/* Retention Graph */}
<section className="retention-section">
<h3>Audience Retention</h3>
<RetentionGraph
data={analytics.retention}
duration={analytics.duration}
/>
</section>
{/* Traffic Sources */}
<section className="traffic-section">
<h3>Traffic Sources</h3>
<TrafficSourcesChart data={analytics.trafficSources} />
</section>
{/* Device Breakdown */}
<section className="devices-section">
<h3>Devices</h3>
<DeviceBreakdown data={analytics.devices} />
</section>
</div>
);
};
// Retention graph showing where viewers drop off
const RetentionGraph = ({
data,
duration,
}: {
data: { time: number; retention: number }[];
duration: number;
}) => {
return (
<div className="retention-graph">
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data}>
<defs>
<linearGradient id="retentionGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="time"
tickFormatter={(t) => formatTime(t)}
stroke="#6b7280"
/>
<YAxis
tickFormatter={(v) => `${v}%`}
domain={[0, 100]}
stroke="#6b7280"
/>
<Tooltip
content={({ payload, label }) => (
<div className="tooltip">
<p>{formatTime(label)}</p>
<p>{payload?.[0]?.value}% still watching</p>
</div>
)}
/>
<Area
type="monotone"
dataKey="retention"
stroke="#3b82f6"
fill="url(#retentionGradient)"
/>
</AreaChart>
</ResponsiveContainer>
{/* Key moments */}
<div className="key-moments">
<KeyMoment label="Intro skip" time={10} percentage={85} />
<KeyMoment label="Most replayed" time={120} percentage={45} />
<KeyMoment label="Major drop" time={300} percentage={20} />
</div>
</div>
);
};
Analytics Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ ANALYTICS & MONITORING ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Data Collection Layer: │
│ ────────────────────── │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │
│ │ Video Events │ │ Performance │ │ Error Tracking │ │
│ │ │ │ │ │ │ │
│ │ • Play/Pause │ │ • Web Vitals │ │ • JS Errors │ │
│ │ • Seek │ │ • TTFF │ │ • Video Errors │ │
│ │ • Buffer │ │ • Buffering │ │ • Network Errors │ │
│ │ • Quality │ │ • API Latency │ │ • API Errors │ │
│ │ • Completed │ │ • Load Times │ │ │ │
│ └───────┬────────┘ └───────┬────────┘ └───────────┬────────────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Event Queue (Local Buffer) │ │
│ │ • Batches events for efficiency │ │
│ │ • Persists to localStorage for reliability │ │
│ │ • Flushes every 10s or on 20 events │ │
│ │ • Uses keepalive for page unload │ │
│ └──────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Analytics API │ │
│ │ POST /api/v1/analytics/events │ │
│ │ POST /api/v1/metrics/performance │ │
│ │ POST /api/v1/errors │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ Key Metrics Tracked: │
│ ──────────────────── │
│ │
│ │ Category │ Metrics │ Target │ │
│ │──────────────│──────────────────────────────────────│──────────────────│ │
│ │ Playback │ Time to First Frame (TTFF) │ < 2s │ │
│ │ │ Initial Buffer Time │ < 1s │ │
│ │ │ Rebuffering Ratio │ < 1% │ │
│ │ │ Avg. Bitrate Delivered │ > 2 Mbps │ │
│ │──────────────│──────────────────────────────────────│──────────────────│ │
│ │ Engagement │ Watch Time │ N/A │ │
│ │ │ Completion Rate │ > 40% │ │
│ │ │ Retention Curve │ Gradual decline │ │
│ │ │ Replay Rate │ N/A │ │
│ │──────────────│──────────────────────────────────────│──────────────────│ │
│ │ Performance │ LCP (Largest Contentful Paint) │ < 2.5s │ │
│ │ │ FID (First Input Delay) │ < 100ms │ │
│ │ │ CLS (Cumulative Layout Shift) │ < 0.1 │ │
│ │──────────────│──────────────────────────────────────│──────────────────│ │
│ │ Errors │ Video Error Rate │ < 0.1% │ │
│ │ │ JS Error Rate │ < 0.5% │ │
│ │ │ API Error Rate │ < 0.1% │ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
21. Notification System
Push Notification Setup
// notifications/PushNotificationService.ts
class PushNotificationService {
private registration: ServiceWorkerRegistration | null = null;
private subscription: PushSubscription | null = null;
async initialize(): Promise<boolean> {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.warn("Push notifications not supported");
return false;
}
try {
// Register service worker
this.registration = await navigator.serviceWorker.register("/sw.js");
// Check existing subscription
this.subscription = await this.registration.pushManager.getSubscription();
return true;
} catch (error) {
console.error("Failed to initialize push notifications:", error);
return false;
}
}
async requestPermission(): Promise<NotificationPermission> {
const permission = await Notification.requestPermission();
return permission;
}
async subscribe(): Promise<PushSubscription | null> {
if (!this.registration) {
await this.initialize();
}
try {
// Get VAPID public key from server
const response = await fetch("/api/v1/notifications/vapid-public-key");
const { publicKey } = await response.json();
const subscription = await this.registration!.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(publicKey),
});
// Send subscription to server
await fetch("/api/v1/notifications/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
this.subscription = subscription;
return subscription;
} catch (error) {
console.error("Failed to subscribe:", error);
return null;
}
}
async unsubscribe(): Promise<boolean> {
if (!this.subscription) return true;
try {
await this.subscription.unsubscribe();
await fetch("/api/v1/notifications/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: this.subscription.endpoint }),
});
this.subscription = null;
return true;
} catch (error) {
console.error("Failed to unsubscribe:", error);
return false;
}
}
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
isSubscribed(): boolean {
return this.subscription !== null;
}
}
// Service Worker Push Handler
// sw.ts
self.addEventListener("push", (event: PushEvent) => {
const data = event.data?.json() ?? {};
const options: NotificationOptions = {
body: data.body,
icon: data.icon || "/icons/notification-icon.png",
badge: "/icons/badge.png",
image: data.image,
tag: data.tag || "default",
data: {
url: data.url,
videoId: data.videoId,
},
actions: data.actions || [],
requireInteraction: data.requireInteraction || false,
};
event.waitUntil(self.registration.showNotification(data.title, options));
});
self.addEventListener("notificationclick", (event: NotificationEvent) => {
event.notification.close();
const { url, videoId } = event.notification.data;
event.waitUntil(
clients.matchAll({ type: "window" }).then((clientList) => {
// Focus existing window if open
for (const client of clientList) {
if (client.url === url && "focus" in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(url || `/watch/${videoId}`);
}
})
);
});
In-App Notification Center
// components/NotificationCenter.tsx
interface Notification {
id: string;
type: "upload" | "live" | "comment" | "mention" | "subscription" | "system";
title: string;
message: string;
thumbnail?: string;
channelAvatar?: string;
videoId?: string;
channelId?: string;
createdAt: Date;
read: boolean;
}
const NotificationCenter = () => {
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
// Fetch notifications
const { data, refetch } = useQuery("notifications", fetchNotifications, {
refetchInterval: 60000, // Refetch every minute
});
// Real-time updates via WebSocket
useEffect(() => {
const ws = new WebSocket(`${WS_URL}/notifications`);
ws.onmessage = (event) => {
const notification = JSON.parse(event.data);
setNotifications((prev) => [notification, ...prev]);
setUnreadCount((prev) => prev + 1);
// Show browser notification if permitted
if (Notification.permission === "granted" && document.hidden) {
new Notification(notification.title, {
body: notification.message,
icon: notification.thumbnail,
});
}
};
return () => ws.close();
}, []);
// Mark as read
const markAsRead = async (id: string) => {
await fetch(`/api/v1/notifications/${id}/read`, { method: "POST" });
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
setUnreadCount((prev) => Math.max(0, prev - 1));
};
// Mark all as read
const markAllAsRead = async () => {
await fetch("/api/v1/notifications/read-all", { method: "POST" });
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(0);
};
return (
<div className="notification-center">
<button
onClick={() => setIsOpen(!isOpen)}
className="notification-bell"
aria-label={`Notifications ${
unreadCount > 0 ? `(${unreadCount} unread)` : ""
}`}
aria-expanded={isOpen}
>
<BellIcon />
{unreadCount > 0 && (
<span className="notification-badge" aria-hidden="true">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{isOpen && (
<div
className="notification-dropdown"
role="dialog"
aria-label="Notifications"
>
<header className="notification-header">
<h2>Notifications</h2>
{unreadCount > 0 && (
<button onClick={markAllAsRead} className="mark-all-read">
Mark all as read
</button>
)}
</header>
<div className="notification-list" role="list">
{notifications.length === 0 ? (
<div className="empty-state">
<BellOffIcon />
<p>No notifications yet</p>
</div>
) : (
notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onRead={() => markAsRead(notification.id)}
/>
))
)}
</div>
<footer className="notification-footer">
<Link href="/notifications">View all notifications</Link>
</footer>
</div>
)}
</div>
);
};
const NotificationItem = ({
notification,
onRead,
}: {
notification: Notification;
onRead: () => void;
}) => {
const handleClick = () => {
if (!notification.read) {
onRead();
}
};
const getIcon = () => {
switch (notification.type) {
case "upload":
return <UploadIcon />;
case "live":
return <LiveIcon className="live-pulse" />;
case "comment":
return <CommentIcon />;
case "mention":
return <AtIcon />;
case "subscription":
return <UserPlusIcon />;
default:
return <BellIcon />;
}
};
return (
<a
href={notification.videoId ? `/watch/${notification.videoId}` : "#"}
className={`notification-item ${notification.read ? "" : "unread"}`}
onClick={handleClick}
role="listitem"
>
<div className="notification-icon">{getIcon()}</div>
{notification.thumbnail ? (
<img
src={notification.thumbnail}
alt=""
className="notification-thumbnail"
/>
) : notification.channelAvatar ? (
<img
src={notification.channelAvatar}
alt=""
className="notification-avatar"
/>
) : null}
<div className="notification-content">
<p className="notification-title">{notification.title}</p>
<p className="notification-message">{notification.message}</p>
<time className="notification-time">
{formatRelativeTime(notification.createdAt)}
</time>
</div>
{!notification.read && (
<span className="unread-dot" aria-label="Unread" />
)}
</a>
);
};
Notification Preferences
// components/NotificationSettings.tsx
interface NotificationPreferences {
subscriptions: boolean;
uploads: boolean;
liveStreams: boolean;
premieres: boolean;
comments: boolean;
mentions: boolean;
replies: boolean;
channelActivity: boolean;
recommendations: boolean;
email: {
enabled: boolean;
frequency: "instant" | "daily" | "weekly";
};
push: {
enabled: boolean;
quiet: {
enabled: boolean;
start: string; // "22:00"
end: string; // "08:00"
};
};
}
const NotificationSettings = () => {
const { data: preferences, isLoading } = useQuery(
"notification-preferences",
fetchNotificationPreferences
);
const mutation = useMutation(updateNotificationPreferences, {
onSuccess: () => {
queryClient.invalidateQueries("notification-preferences");
},
});
const handleToggle = (key: keyof NotificationPreferences, value: boolean) => {
mutation.mutate({ [key]: value });
};
if (isLoading) return <Skeleton />;
return (
<div className="notification-settings">
<h2>Notification Preferences</h2>
<section className="settings-section">
<h3>Subscriptions</h3>
<ToggleRow
label="New uploads"
description="Get notified when channels you subscribe to upload new videos"
checked={preferences.uploads}
onChange={(v) => handleToggle("uploads", v)}
/>
<ToggleRow
label="Live streams"
description="Get notified when channels go live"
checked={preferences.liveStreams}
onChange={(v) => handleToggle("liveStreams", v)}
/>
<ToggleRow
label="Premieres"
description="Get notified about scheduled premieres"
checked={preferences.premieres}
onChange={(v) => handleToggle("premieres", v)}
/>
</section>
<section className="settings-section">
<h3>Activity on your content</h3>
<ToggleRow
label="Comments"
description="Get notified about new comments on your videos"
checked={preferences.comments}
onChange={(v) => handleToggle("comments", v)}
/>
<ToggleRow
label="Mentions"
description="Get notified when someone mentions you"
checked={preferences.mentions}
onChange={(v) => handleToggle("mentions", v)}
/>
<ToggleRow
label="Replies"
description="Get notified about replies to your comments"
checked={preferences.replies}
onChange={(v) => handleToggle("replies", v)}
/>
</section>
<section className="settings-section">
<h3>Push Notifications</h3>
<ToggleRow
label="Enable push notifications"
description="Receive notifications even when the app is closed"
checked={preferences.push.enabled}
onChange={async (v) => {
if (v) {
const permission = await Notification.requestPermission();
if (permission === "granted") {
handleToggle("push", { ...preferences.push, enabled: true });
}
} else {
handleToggle("push", { ...preferences.push, enabled: false });
}
}}
/>
{preferences.push.enabled && (
<div className="quiet-hours">
<ToggleRow
label="Quiet hours"
description="Pause notifications during specific hours"
checked={preferences.push.quiet.enabled}
onChange={(v) =>
handleToggle("push", {
...preferences.push,
quiet: { ...preferences.push.quiet, enabled: v },
})
}
/>
{preferences.push.quiet.enabled && (
<div className="time-range">
<TimeInput
label="From"
value={preferences.push.quiet.start}
onChange={(v) =>
handleToggle("push", {
...preferences.push,
quiet: { ...preferences.push.quiet, start: v },
})
}
/>
<TimeInput
label="To"
value={preferences.push.quiet.end}
onChange={(v) =>
handleToggle("push", {
...preferences.push,
quiet: { ...preferences.push.quiet, end: v },
})
}
/>
</div>
)}
</div>
)}
</section>
<section className="settings-section">
<h3>Email Notifications</h3>
<ToggleRow
label="Enable email notifications"
checked={preferences.email.enabled}
onChange={(v) =>
handleToggle("email", { ...preferences.email, enabled: v })
}
/>
{preferences.email.enabled && (
<RadioGroup
label="Frequency"
value={preferences.email.frequency}
options={[
{ value: "instant", label: "Instant" },
{ value: "daily", label: "Daily digest" },
{ value: "weekly", label: "Weekly digest" },
]}
onChange={(v) =>
handleToggle("email", { ...preferences.email, frequency: v })
}
/>
)}
</section>
</div>
);
};
Notification Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ NOTIFICATION SYSTEM ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Notification Sources: │
│ ───────────────────── │
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │
│ │ Content Events │ │ Social Events │ │ System Events │ │
│ │ │ │ │ │ │ │
│ │ • New upload │ │ • Comment │ │ • Security alert │ │
│ │ • Live start │ │ • Reply │ │ • Policy update │ │
│ │ • Premiere │ │ • Mention │ │ • Account activity │ │
│ │ • Community │ │ • Like │ │ • Feature announcement │ │
│ └───────┬────────┘ └───────┬────────┘ └───────────┬────────────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Notification Router │ │
│ │ │ │
│ │ 1. Check user preferences │ │
│ │ 2. Apply frequency rules │ │
│ │ 3. Check quiet hours │ │
│ │ 4. Route to appropriate channels │ │
│ └──────────────────────────────┬───────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │
│ │ In-App │ │ Push │ │ Email │ │
│ │ │ │ │ │ │ │
│ │ • WebSocket │ │ • Web Push API │ │ • Instant │ │
│ │ • Notification │ │ • FCM (Mobile) │ │ • Daily digest │ │
│ │ Center │ │ • APNs (iOS) │ │ • Weekly digest │ │
│ │ • Badge count │ │ │ │ │ │
│ └────────────────┘ └────────────────┘ └────────────────────────┘ │
│ │
│ Delivery Flow: │
│ ────────────── │
│ │
│ Event Occurs │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Get Subscribers │ (Channels, video owners, mentioned users) │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Fan-out to each │ (Batch process for large subscriber counts) │
│ │ subscriber │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ ▼ ▼ │
│ [Queue] [WebSocket] │
│ │ │ │
│ ▼ ▼ │
│ Async Real-time │
│ Delivery Delivery │
│ │
│ Notification Types: │
│ ─────────────────── │
│ │
│ │ Type │ Priority │ Channels │ Quiet Hours │ │
│ │──────────────│──────────│────────────────────│─────────────│ │
│ │ Upload │ Normal │ Push, In-App │ Respects │ │
│ │ Live │ High │ Push, In-App, SMS │ Override │ │
│ │ Comment │ Low │ In-App │ Respects │ │
│ │ Mention │ Normal │ Push, In-App │ Respects │ │
│ │ Security │ Critical │ Push, Email, SMS │ Override │ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
22. Live Streaming Deep Dive
Low-Latency Live Player
// LivePlayer.tsx - Ultra-low latency live streaming player
interface LivePlayerProps {
streamId: string;
channel: Channel;
onChatMessage?: (message: ChatMessage) => void;
}
const LivePlayer = ({ streamId, channel, onChatMessage }: LivePlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [isLive, setIsLive] = useState(true);
const [latency, setLatency] = useState(0);
const [viewerCount, setViewerCount] = useState(0);
const [quality, setQuality] = useState<"auto" | number>("auto");
// Initialize low-latency HLS
useEffect(() => {
if (!Hls.isSupported()) {
console.error("HLS not supported");
return;
}
const hls = new Hls({
// Low-latency configuration
lowLatencyMode: true,
liveSyncDuration: 3, // Target 3 seconds behind live edge
liveMaxLatencyDuration: 10, // Max 10 seconds behind
liveDurationInfinity: true, // Treat as infinite duration
highBufferWatchdogPeriod: 1, // Check buffer every second
// ABR settings for live
abrEwmaFastLive: 3,
abrEwmaSlowLive: 9,
abrBandWidthFactor: 0.7, // More conservative for live
abrBandWidthUpFactor: 0.5, // Slower to switch up in live
// Start with lower quality, ramp up
startLevel: -1, // Auto-select starting level
capLevelToPlayerSize: true, // Don't load higher than viewport
// Backbuffer for rewind
backBufferLength: 30, // Keep 30 seconds for instant replay
});
hlsRef.current = hls;
hls.attachMedia(videoRef.current!);
// Load manifest
hls.loadSource(`/api/v1/live/${streamId}/manifest.m3u8`);
// Track latency
hls.on(Hls.Events.LEVEL_UPDATED, (_, data) => {
if (videoRef.current) {
const liveEdge = data.details.edge || 0;
const currentLatency = liveEdge - videoRef.current.currentTime;
setLatency(Math.max(0, currentLatency));
}
});
// Handle manifest errors
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
if (data.details === "manifestLoadError") {
// Stream might have ended
setIsLive(false);
} else {
// Network hiccup, try to recover
hls.startLoad();
}
}
});
return () => {
hls.destroy();
};
}, [streamId]);
// Real-time viewer count via WebSocket
useEffect(() => {
const ws = new WebSocket(`${WS_URL}/live/${streamId}/stats`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setViewerCount(data.viewerCount);
if (data.status === "ended") {
setIsLive(false);
}
};
return () => ws.close();
}, [streamId]);
// Catch up to live
const catchUpToLive = useCallback(() => {
if (videoRef.current && hlsRef.current) {
const liveEdge = hlsRef.current.liveSyncPosition;
if (liveEdge) {
videoRef.current.currentTime = liveEdge;
}
}
}, []);
// Toggle low latency mode
const toggleLowLatency = useCallback(
(enabled: boolean) => {
if (hlsRef.current) {
hlsRef.current.config.lowLatencyMode = enabled;
hlsRef.current.config.liveSyncDuration = enabled ? 3 : 10;
catchUpToLive();
}
},
[catchUpToLive]
);
return (
<div className="live-player">
{/* Video element */}
<video
ref={videoRef}
autoPlay
muted
playsInline
className="video-element"
/>
{/* Live badge */}
{isLive && (
<div className="live-badge">
<span className="live-dot" />
LIVE
</div>
)}
{/* Viewer count */}
<div className="viewer-count">
<EyeIcon />
{formatViewerCount(viewerCount)} watching
</div>
{/* Latency indicator */}
<div className="latency-indicator">
<button
onClick={catchUpToLive}
disabled={latency < 5}
aria-label={`${latency.toFixed(
1
)} seconds behind live. Click to catch up.`}
>
{latency.toFixed(1)}s behind
{latency > 10 && (
<span className="catch-up-hint">Click to catch up</span>
)}
</button>
</div>
{/* Stream ended overlay */}
{!isLive && (
<div className="stream-ended-overlay">
<h2>Stream has ended</h2>
<p>Check back later for the replay</p>
<button
onClick={() => (window.location.href = `/channel/${channel.id}`)}
>
Visit Channel
</button>
</div>
)}
{/* Custom controls for live */}
<LiveControls
videoRef={videoRef}
quality={quality}
onQualityChange={setQuality}
latency={latency}
onCatchUp={catchUpToLive}
isLive={isLive}
/>
</div>
);
};
Live Chat Integration
// LiveChat.tsx - Real-time chat for live streams
interface ChatMessage {
id: string;
userId: string;
username: string;
avatar: string;
message: string;
timestamp: number;
type: "message" | "superchat" | "membership" | "sticker";
badges: string[];
amount?: number;
currency?: string;
}
const LiveChat = ({ streamId }: { streamId: string }) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState("");
const [isConnected, setIsConnected] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const chatContainerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const pendingMessagesRef = useRef<ChatMessage[]>([]);
// WebSocket connection for chat
useEffect(() => {
const ws = new WebSocket(`${WS_URL}/live/${streamId}/chat`);
wsRef.current = ws;
ws.onopen = () => setIsConnected(true);
ws.onclose = () => setIsConnected(false);
ws.onmessage = (event) => {
const message: ChatMessage = JSON.parse(event.data);
if (isPaused) {
pendingMessagesRef.current.push(message);
return;
}
setMessages((prev) => {
const updated = [...prev, message];
// Keep only last 500 messages for performance
return updated.slice(-500);
});
};
return () => ws.close();
}, [streamId, isPaused]);
// Auto-scroll to bottom
useEffect(() => {
if (!isPaused && chatContainerRef.current) {
chatContainerRef.current.scrollTop =
chatContainerRef.current.scrollHeight;
}
}, [messages, isPaused]);
// Resume chat and flush pending
const resumeChat = useCallback(() => {
setIsPaused(false);
setMessages((prev) => [...prev, ...pendingMessagesRef.current].slice(-500));
pendingMessagesRef.current = [];
}, []);
// Send message
const sendMessage = useCallback(() => {
if (!inputValue.trim() || !wsRef.current) return;
wsRef.current.send(
JSON.stringify({
type: "message",
message: inputValue.trim(),
})
);
setInputValue("");
}, [inputValue]);
return (
<div className="live-chat">
<header className="chat-header">
<h3>Live Chat</h3>
<span className={`connection-status ${isConnected ? "connected" : ""}`}>
{isConnected ? "Connected" : "Reconnecting..."}
</span>
</header>
{/* Chat messages */}
<div
ref={chatContainerRef}
className="chat-messages"
onScroll={(e) => {
const isNearBottom =
e.currentTarget.scrollHeight - e.currentTarget.scrollTop <
e.currentTarget.clientHeight + 100;
setIsPaused(!isNearBottom);
}}
role="log"
aria-live="polite"
aria-label="Chat messages"
>
{messages.map((msg) => (
<ChatMessageItem key={msg.id} message={msg} />
))}
</div>
{/* Paused indicator */}
{isPaused && (
<button className="resume-chat" onClick={resumeChat}>
Chat paused - {pendingMessagesRef.current.length} new messages
</button>
)}
{/* Input */}
<div className="chat-input">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
placeholder="Say something..."
maxLength={200}
disabled={!isConnected}
/>
<button
onClick={sendMessage}
disabled={!inputValue.trim() || !isConnected}
aria-label="Send message"
>
<SendIcon />
</button>
</div>
</div>
);
};
const ChatMessageItem = ({ message }: { message: ChatMessage }) => {
if (message.type === "superchat") {
return (
<div
className="superchat-message"
style={{ backgroundColor: getSuperChatColor(message.amount!) }}
>
<img src={message.avatar} alt="" className="avatar" />
<div className="superchat-content">
<span className="username">{message.username}</span>
<span className="amount">
{formatCurrency(message.amount!, message.currency!)}
</span>
<p className="message">{message.message}</p>
</div>
</div>
);
}
return (
<div className="chat-message">
<img src={message.avatar} alt="" className="avatar" />
<div className="message-content">
<span className="badges">
{message.badges.map((badge) => (
<img key={badge} src={`/badges/${badge}.png`} alt={badge} />
))}
</span>
<span className="username">{message.username}</span>
<span className="message">{message.message}</span>
</div>
</div>
);
};
Live Stream Dashboard (Creator)
// LiveDashboard.tsx - Creator's live streaming control panel
const LiveDashboard = ({ streamId }: { streamId: string }) => {
const [streamStatus, setStreamStatus] = useState<
"offline" | "live" | "ending"
>("offline");
const [stats, setStats] = useState({
viewers: 0,
peakViewers: 0,
likes: 0,
chatMessages: 0,
duration: 0,
});
// Real-time stats
useEffect(() => {
const ws = new WebSocket(`${WS_URL}/dashboard/${streamId}/stats`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setStats(data);
setStreamStatus(data.status);
};
return () => ws.close();
}, [streamId]);
return (
<div className="live-dashboard">
{/* Stream preview */}
<section className="preview-section">
<div className="preview-container">
<video
src={`/api/v1/live/${streamId}/preview`}
autoPlay
muted
className="preview-video"
/>
<div className="preview-overlay">
{streamStatus === "live" && (
<span className="live-indicator">LIVE</span>
)}
</div>
</div>
{/* Stream actions */}
<div className="stream-actions">
{streamStatus === "offline" && (
<button
onClick={() => startStream(streamId)}
className="go-live-button"
>
Go Live
</button>
)}
{streamStatus === "live" && (
<button
onClick={() => endStream(streamId)}
className="end-stream-button"
>
End Stream
</button>
)}
</div>
</section>
{/* Real-time stats */}
<section className="stats-section">
<div className="stat-grid">
<StatCard
label="Current Viewers"
value={formatNumber(stats.viewers)}
icon={<UsersIcon />}
trend={stats.viewers > stats.peakViewers * 0.9 ? "up" : "stable"}
/>
<StatCard
label="Peak Viewers"
value={formatNumber(stats.peakViewers)}
icon={<TrendingUpIcon />}
/>
<StatCard
label="Likes"
value={formatNumber(stats.likes)}
icon={<ThumbsUpIcon />}
/>
<StatCard
label="Chat Messages"
value={formatNumber(stats.chatMessages)}
icon={<MessageIcon />}
/>
<StatCard
label="Duration"
value={formatDuration(stats.duration)}
icon={<ClockIcon />}
/>
</div>
</section>
{/* Stream health */}
<section className="health-section">
<h3>Stream Health</h3>
<StreamHealthMonitor streamId={streamId} />
</section>
{/* Live chat moderation */}
<section className="chat-section">
<h3>Chat Moderation</h3>
<ChatModerationPanel streamId={streamId} />
</section>
</div>
);
};
// Stream health monitoring
const StreamHealthMonitor = ({ streamId }: { streamId: string }) => {
const [health, setHealth] = useState({
bitrate: 0,
fps: 0,
keyframeInterval: 0,
droppedFrames: 0,
connectionQuality: "good" as "good" | "fair" | "poor",
});
useEffect(() => {
const interval = setInterval(async () => {
const response = await fetch(`/api/v1/live/${streamId}/health`);
const data = await response.json();
setHealth(data);
}, 2000);
return () => clearInterval(interval);
}, [streamId]);
return (
<div className="health-monitor">
<div className="health-metric">
<span className="label">Bitrate</span>
<span className="value">{(health.bitrate / 1000).toFixed(1)} Kbps</span>
<HealthIndicator
status={
health.bitrate > 4000
? "good"
: health.bitrate > 2000
? "fair"
: "poor"
}
/>
</div>
<div className="health-metric">
<span className="label">Frame Rate</span>
<span className="value">{health.fps} fps</span>
<HealthIndicator
status={
health.fps >= 28 ? "good" : health.fps >= 20 ? "fair" : "poor"
}
/>
</div>
<div className="health-metric">
<span className="label">Dropped Frames</span>
<span className="value">{health.droppedFrames}</span>
<HealthIndicator
status={
health.droppedFrames < 10
? "good"
: health.droppedFrames < 50
? "fair"
: "poor"
}
/>
</div>
<div className="health-metric">
<span className="label">Connection</span>
<span className={`value ${health.connectionQuality}`}>
{health.connectionQuality.toUpperCase()}
</span>
</div>
</div>
);
};
Live Streaming Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ LIVE STREAMING ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Ingest Flow: │
│ ──────────── │
│ │
│ Broadcaster │
│ (OBS/StreamLabs) │
│ │ │
│ │ RTMP/SRT │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Ingest Server │ (Regional POPs) │
│ │ • Protocol │ │
│ │ conversion │ │
│ │ • Authentication│ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Transcoder │ (ABR encoding) │
│ │ • Multi-bitrate │ │
│ │ • Low-latency │ │
│ │ segments │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Origin/Packager │ (HLS-LL, DASH-LL) │
│ │ • Segment │ │
│ │ packaging │ │
│ │ • Manifest │ │
│ │ generation │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ CDN Edge │ (Global distribution) │
│ │ • Caching │ │
│ │ • HTTP/2 Push │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Viewer Player │ │
│ │ • HLS.js │ │
│ │ • Low-latency │ │
│ │ mode │ │
│ └──────────────────┘ │
│ │
│ Latency Targets: │
│ ──────────────── │
│ │
│ │ Mode │ Target Latency │ Trade-off │ │
│ │────────────────────│────────────────│───────────────────────────────────│ │
│ │ Ultra-Low Latency │ 2-4 seconds │ More buffering, lower quality │ │
│ │ Low Latency │ 4-8 seconds │ Balanced │ │
│ │ Standard │ 15-30 seconds │ Best quality, stable playback │ │
│ │
│ Live Chat Architecture: │
│ ─────────────────────── │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Viewer │────►│ WebSocket │────►│ Chat Server │ │
│ │ Browser │◄────│ Gateway │◄────│ (Redis Pub/Sub) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────────┐ │
│ │ │ Content Moderation │ │
│ │ │ • Spam filter │ │
│ │ │ • Word filter │ │
│ │ │ • Rate limiting │ │
│ │ └─────────────────────┘ │
│ │ │
│ └─────────────────────────────────► │
│ Broadcast to all viewers │
│ │
│ DVR / Rewind Capability: │
│ ──────────────────────── │
│ • Keep last 2 hours in edge cache │
│ • Allow seeking back within live window │
│ • "Go Live" button to jump to live edge │
│ • Background recording for VOD conversion │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Top comments (0)