Skip to main content

Media

Video streaming platform featuring The Couch (curated films) and Feed (user content).

Overview

Media is TrickBook's video and content platform with two main sections:

  1. The Couch - Curated action sports films, documentaries, and professional edits
  2. Feed - User-generated content with social features (reactions, comments)

The Couch

Overview

The Couch is a streaming platform for full-length action sports videos, hosted on Bunny.net CDN with HLS adaptive streaming.

Frontend Implementation (Website)

Location: /pages/media/

FilePurpose
index.jsMedia hub with tabs for Couch/Feed
couch/index.jsThe Couch video library
couch/[id].jsIndividual video player page

Components:

ComponentLocationPurpose
VideoCard.js/components/media/VideoCard.jsVideo thumbnail with metadata
VideoPlayer.js/components/media/VideoPlayer.jsHLS video player with controls
VideoFilters.js/components/media/VideoFilters.jsFilter by sport, type, year

Key Features:

  • HLS adaptive bitrate streaming
  • Sport type filtering (skateboarding, snowboarding, surfing, BMX)
  • Content type filtering (film, documentary, contest, part)
  • Featured videos carousel
  • Video reactions (love, respect)
  • View count tracking
  • Responsive video player

Video Player Implementation

File: /components/media/VideoPlayer.js

import Hls from "hls.js";
import { useRef, useEffect } from "react";

export default function VideoPlayer({ src, poster, autoPlay, controls }) {
const videoRef = useRef(null);

useEffect(() => {
const video = videoRef.current;
if (!video || !src) return;

if (src.includes(".m3u8") && Hls.isSupported()) {
const hls = new Hls({
maxLoadingDelay: 4,
maxBufferLength: 30,
maxBufferSize: 60 * 1000 * 1000,
});
hls.loadSource(src);
hls.attachMedia(video);
return () => hls.destroy();
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
// Safari native HLS support
video.src = src;
}
}, [src]);

return (
<video
ref={videoRef}
poster={poster}
autoPlay={autoPlay}
controls={controls}
playsInline
className="w-full h-full object-contain"
/>
);
}

Admin Panel

Location: /pages/admin/couch/

FilePurpose
index.jsVideo management list
new.jsAdd new video
edit/[id].jsEdit video details

Admin Features:

  • YouTube metadata import (title, description, thumbnail)
  • Direct video upload to Bunny.net
  • Thumbnail upload to S3 (file upload or URL)
  • Sport type and tag management
  • Featured video toggle
  • Publish/unpublish controls
  • Rider and sponsor credits

Thumbnail Upload Implementation:

// File upload to S3
const handleThumbnailUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;

// Validate
if (!file.type.startsWith("image/")) {
setError("Please select an image file");
return;
}
if (file.size > 5 * 1024 * 1024) {
setError("Thumbnail must be less than 5MB");
return;
}

setUploadingThumbnail(true);
const result = await uploadImageToS3(file, token, setThumbnailProgress);

setFormData(prev => ({
...prev,
thumbnails: { ...prev.thumbnails, poster: result.fileUrl }
}));
setUploadingThumbnail(false);
};

Backend Implementation

Collection: couch_videos

{
_id: ObjectId,
title: String,
description: String,

// Classification
sportTypes: [String], // ["skateboarding", "snowboarding"]
type: String, // "film", "documentary", "part", "contest"
tags: [String], // ["street", "vert", "classic"]

// Credits
releaseYear: Number,
producedBy: String,
riders: [String],
sponsors: [String],
duration: Number, // Seconds

// Video Sources
bunnyVideoId: String, // Bunny.net video ID
hlsUrl: String, // HLS playlist URL
driveFileId: String, // Legacy Google Drive
driveThumbnail: String, // Legacy thumbnail
youtubeUrl: String, // YouTube link (fallback)

// Thumbnails
thumbnails: {
poster: String, // S3 URL - main thumbnail
backdrop: String // S3 URL - wide banner
},

// Stats
stats: {
viewCount: Number,
loveCount: Number,
respectCount: Number,
commentCount: Number
},

// Publishing
isPublished: Boolean,
isFeatured: Boolean,

createdAt: Date,
updatedAt: Date
}

API Endpoints (The Couch)

MethodEndpointDescriptionAuth
GET/media/couchList videos with filtersNo
GET/media/couch/:idGet video detailsNo
GET/media/couch/:id/streamGet stream URLNo
POST/media/couch/:id/viewIncrement view countNo
POST/media/couch/:id/reactionAdd reactionJWT
DELETE/media/couch/:id/reactionRemove reactionJWT
POST/media/couchCreate video (admin)Admin
PUT/media/couch/:idUpdate video (admin)Admin
DELETE/media/couch/:idDelete video (admin)Admin

Bunny.net Integration

Video Upload Flow:

  1. Admin enters video title
  2. Backend creates Bunny video entry via API
  3. Frontend uploads file directly to Bunny.net
  4. Bunny processes and creates HLS streams
  5. Backend stores bunnyVideoId and hlsUrl

File: /lib/apiMedia.js

export async function createBunnyVideo(title, token) {
const response = await axios.post(
`${API_BASE}/media/couch/bunny/create`,
{ title },
{ headers: { "x-auth-token": token } }
);
return response.data; // { guid, libraryId, uploadKey, cdnHostname }
}

Backend Bunny API Call:

// Create video in Bunny library
const bunnyResponse = await axios.post(
`https://video.bunnycdn.com/library/${BUNNY_LIBRARY_ID}/videos`,
{ title },
{ headers: { AccessKey: BUNNY_API_KEY } }
);

return {
guid: bunnyResponse.data.guid,
libraryId: BUNNY_LIBRARY_ID,
uploadKey: BUNNY_API_KEY,
cdnHostname: BUNNY_CDN_HOSTNAME
};

Feed

Overview

The Feed is a user-generated content platform where riders can share clips, photos, and updates with the community.

Frontend Implementation (Website)

Location: /pages/media/

FilePurpose
feed/index.jsMain feed view
feed/[id].jsIndividual post page
feed/upload.jsUpload new post

Components:

ComponentLocationPurpose
FeedPost.js/components/media/FeedPost.jsPost card with media, reactions, comments
VideoPlayer.js/components/media/VideoPlayer.jsVideo player with autoplay on scroll
CommentSection.js/components/media/CommentSection.jsComments with replies
ReactionBar.js/components/media/ReactionBar.jsLove/respect buttons

Key Features:

  • Video and image posts
  • Autoplay videos when in viewport (IntersectionObserver)
  • Audio on by default for feed videos
  • Love and Respect reactions
  • Comments with nested replies
  • Trick tags linking to Trickipedia
  • Sport type tags
  • User profile links
  • Share and save functionality

FeedPost Component

File: /components/media/FeedPost.js

export default function FeedPost({ post, onReaction, autoPlay = false }) {
const [loved, setLoved] = useState(post.userReactions?.includes("love"));
const [respected, setRespected] = useState(post.userReactions?.includes("respect"));
const [showComments, setShowComments] = useState(false);
const [isInView, setIsInView] = useState(false);
const containerRef = useRef(null);

// Autoplay when in viewport
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setIsInView(entry.isIntersecting && entry.intersectionRatio >= 0.5),
{ threshold: 0.5 }
);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);

const handleReaction = async (type) => {
// Optimistic update
if (type === "love") {
setLoved(!loved);
setLoveCount(c => loved ? c - 1 : c + 1);
}
// ... API call with rollback on error
};

return (
<div ref={containerRef}>
{/* Creator header */}
{/* Video/Image content */}
<VideoPlayer
src={post.hlsUrl || post.videoUrl}
poster={post.thumbnailUrl}
autoPlay={autoPlay && isInView}
controls
/>
{/* Reaction bar */}
{/* Comments section */}
</div>
);
}

Backend Implementation (Feed)

Collection: feed_posts

{
_id: ObjectId,
user: ObjectId, // Post author

// Content
mediaType: String, // "video", "image"
caption: String,

// Video fields
videoUrl: String, // Direct video URL
hlsUrl: String, // HLS stream URL
thumbnailUrl: String,
aspectRatio: String, // "9:16", "16:9", "1:1"
duration: Number,

// Image fields
imageUrls: [String], // Multiple images

// Tags
tricks: [String], // Trick names (link to Trickipedia)
sportTypes: [String],
location: {
name: String,
coordinates: [Number] // [lng, lat]
},

// Stats
stats: {
loveCount: Number,
respectCount: Number,
commentCount: Number,
shareCount: Number,
viewCount: Number
},

// Privacy
visibility: String, // "public", "homies", "private"

createdAt: Date,
updatedAt: Date
}

Collection: feed_reactions

{
_id: ObjectId,
postId: ObjectId,
userId: ObjectId,
type: String, // "love", "respect"
createdAt: Date
}
// Index: { postId: 1, userId: 1, type: 1 } unique

Collection: feed_comments

{
_id: ObjectId,
postId: ObjectId,
userId: ObjectId,
content: String,
parentCommentId: ObjectId, // For replies (null if top-level)

// Stats
loveCount: Number,
replyCount: Number,

isDeleted: Boolean, // Soft delete
createdAt: Date,
updatedAt: Date
}

API Endpoints (Feed)

MethodEndpointDescriptionAuth
GET/feedGet feed posts (paginated)No
GET/feed/:idGet single postNo
POST/feedCreate postJWT
DELETE/feed/:idDelete postJWT
POST/feed/:id/reactionAdd reactionJWT
DELETE/feed/:id/reaction/:typeRemove reactionJWT
GET/feed/:id/commentsGet commentsNo
POST/feed/:id/commentsAdd commentJWT
DELETE/feed/:id/comments/:commentIdDelete commentJWT
POST/feed/:id/comments/:commentId/loveLove commentJWT

Real-Time Comments (Socket.IO)

Events:

EventDirectionDescription
join:postClient → ServerSubscribe to post comments
leave:postClient → ServerUnsubscribe
comment:newServer → ClientNew comment added
comment:deletedServer → ClientComment removed
reaction:updateServer → ClientReaction count changed

Video Upload Pipeline

User Upload Flow (Feed)

  1. User selects video file
  2. Frontend validates (format, size, duration)
  3. Create feed post entry in database
  4. Upload video to Bunny.net via TUS protocol
  5. Bunny processes video (transcoding, HLS)
  6. Backend updates post with stream URLs
  7. Post appears in feed

TUS Upload Implementation

File: /lib/apiUpload.js

export async function uploadVideoTUS(file, credentials, onProgress) {
const tus = await import("tus-js-client");

return new Promise((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: credentials.tusEndpoint,
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: {
AuthorizationSignature: credentials.headers.AuthorizationSignature,
AuthorizationExpire: credentials.headers.AuthorizationExpire,
VideoId: credentials.headers.VideoId,
LibraryId: credentials.headers.LibraryId,
},
metadata: {
filename: file.name,
filetype: file.type,
},
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = (bytesUploaded / bytesTotal) * 100;
onProgress?.(percentage);
},
onSuccess: () => resolve({ videoId: credentials.videoId, success: true }),
onError: (error) => reject(error),
});

upload.findPreviousUploads().then((previousUploads) => {
if (previousUploads.length > 0) {
upload.resumeFromPreviousUpload(previousUploads[0]);
}
upload.start();
});
});
}

Infrastructure

Video Hosting: Bunny.net

  • Library ID: Configured in environment
  • CDN Hostname: {library}.b-cdn.net
  • Features: HLS adaptive streaming, global CDN, thumbnails

Image Storage: AWS S3

  • Bucket: trickbook
  • Paths: /feed/{postId}/, /couch/{videoId}/
  • Access: Public read via bucket policy

Environment Variables

# Bunny.net
BUNNY_API_KEY=xxx
BUNNY_LIBRARY_ID=xxx
BUNNY_CDN_HOSTNAME=xxx.b-cdn.net

# AWS S3
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_S3_BUCKET=trickbook
AWS_REGION=us-east-1

Mobile Considerations

Video Playback

  • Use expo-av for video playback
  • HLS support via native players
  • Background audio handling
  • Picture-in-picture (future)

Feed Optimization

  • Preload next videos
  • Pause off-screen videos
  • Cache thumbnails
  • Infinite scroll pagination

Upload

  • Background upload support
  • Progress tracking
  • Resume interrupted uploads
  • Compress before upload