PlayVideo

Video Hosting for Next.js

Add video upload and playback to your Next.js app in minutes.

Installation

npm install @playvideo/playvideo-sdk

Setup

Environment Variables

# .env.local
BLOCKBUSTER_API_KEY=play_live_your_api_key

Initialize Client (Server-Side Only)

// lib/playvideo.ts
import PlayVideo from '@playvideo/playvideo-sdk';

export const bb = new PlayVideo(process.env.BLOCKBUSTER_API_KEY!);

Important: Only use the PlayVideo client in server-side code (API routes, Server Components, server actions). Never expose your API key to the browser.

Upload Videos

Option 1: Server Action (App Router)

// app/actions/upload.ts
'use server';

import { bb } from '@/lib/playvideo';

export async function uploadVideo(formData: FormData) {
  const file = formData.get('video') as File;
  
  // Convert File to Buffer
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  const result = await bb.videos.upload({
    file: buffer,
    collection: 'user-uploads',
    filename: file.name
  });

  return result.video;
}
// app/upload/page.tsx
'use client';

import { uploadVideo } from './actions';
import { useState } from 'react';

export default function UploadPage() {
  const [uploading, setUploading] = useState(false);

  async function handleSubmit(formData: FormData) {
    setUploading(true);
    const video = await uploadVideo(formData);
    console.log('Uploaded:', video.id);
    setUploading(false);
  }

  return (
    <form action={handleSubmit}>
      <input type="file" name="video" accept="video/*" />
      <button type="submit" disabled={uploading}>
        {uploading ? 'Uploading...' : 'Upload Video'}
      </button>
    </form>
  );
}

Option 2: API Route

// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { bb } from '@/lib/playvideo';

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get('video') as File;

  if (!file) {
    return NextResponse.json({ error: 'No file provided' }, { status: 400 });
  }

  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  const result = await bb.videos.upload({
    file: buffer,
    collection: 'user-uploads',
    filename: file.name
  });

  return NextResponse.json(result.video);
}

Display Videos

Server Component

// app/videos/page.tsx
import { bb } from '@/lib/playvideo';
import VideoPlayer from '@/components/VideoPlayer';

export default async function VideosPage() {
  const { videos } = await bb.videos.list({
    collection: 'user-uploads',
    status: 'COMPLETED'
  });

  return (
    <div>
      <h1>My Videos</h1>
      <div className="grid grid-cols-3 gap-4">
        {videos.map(video => (
          <div key={video.id}>
            <VideoPlayer url={video.playlistUrl!} />
            <p>{video.filename}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Video Player Component

// components/VideoPlayer.tsx
'use client';

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

interface VideoPlayerProps {
  url: string;
  poster?: string;
}

export default function VideoPlayer({ url, poster }: VideoPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);

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

    if (Hls.isSupported()) {
      const hls = new Hls();
      hls.loadSource(url);
      hls.attachMedia(video);
      
      return () => hls.destroy();
    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
      // Safari native HLS
      video.src = url;
    }
  }, [url]);

  return (
    <video 
      ref={videoRef} 
      controls 
      poster={poster}
      className="w-full aspect-video"
    />
  );
}

Install hls.js:

npm install hls.js

Upload with Progress

// components/VideoUploader.tsx
'use client';

import { useState, useRef } from 'react';

export default function VideoUploader() {
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'done'>('idle');
  const [videoUrl, setVideoUrl] = useState<string | null>(null);

  async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;

    setStatus('uploading');
    
    // Upload to your API route
    const formData = new FormData();
    formData.append('video', file);

    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData
    });

    const video = await response.json();
    setStatus('processing');

    // Poll for completion
    const checkStatus = async () => {
      const res = await fetch(`/api/videos/${video.id}`);
      const updated = await res.json();
      
      if (updated.status === 'COMPLETED') {
        setVideoUrl(updated.playlistUrl);
        setStatus('done');
      } else if (updated.status === 'FAILED') {
        setStatus('idle');
        alert('Processing failed: ' + updated.errorMessage);
      } else {
        setProgress(updated.progress || 0);
        setTimeout(checkStatus, 2000);
      }
    };

    checkStatus();
  }

  return (
    <div>
      {status === 'idle' && (
        <input type="file" accept="video/*" onChange={handleUpload} />
      )}
      
      {status === 'uploading' && <p>Uploading...</p>}
      
      {status === 'processing' && (
        <div>
          <p>Processing: {progress}%</p>
          <progress value={progress} max={100} />
        </div>
      )}
      
      {status === 'done' && videoUrl && (
        <video src={videoUrl} controls className="w-full" />
      )}
    </div>
  );
}

Webhooks for Real-Time Updates

Instead of polling, use webhooks for production:

// app/api/webhooks/playvideo/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const payload = await request.json();
  
  switch (payload.event) {
    case 'video.completed':
      // Update your database, notify user, etc.
      console.log('Video ready:', payload.video.playlistUrl);
      break;
    case 'video.failed':
      console.error('Video failed:', payload.video.errorMessage);
      break;
  }

  return NextResponse.json({ received: true });
}
// app/gallery/page.tsx
import { bb } from '@/lib/playvideo';
import VideoPlayer from '@/components/VideoPlayer';
import VideoUploader from '@/components/VideoUploader';

export const revalidate = 60; // Revalidate every 60 seconds

export default async function GalleryPage() {
  const { videos } = await bb.videos.list({
    status: 'COMPLETED',
    limit: 20
  });

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Video Gallery</h1>
      
      <div className="mb-8">
        <h2 className="text-lg mb-2">Upload New Video</h2>
        <VideoUploader />
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {videos.map(video => (
          <div key={video.id} className="border rounded p-4">
            <VideoPlayer 
              url={video.playlistUrl!} 
              poster={video.thumbnailUrl!}
            />
            <p className="mt-2 text-sm">{video.filename}</p>
            <p className="text-xs text-gray-500">
              {Math.round(video.duration || 0)}s • {video.resolutions?.join(', ')}
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

Using with Next.js Image

Display video thumbnails with Next.js Image optimization:

import Image from 'next/image';

// In next.config.js, add the CDN domain
// images: { domains: ['cdn.playvideo.dev'] }

<Image 
  src={video.thumbnailUrl!}
  alt={video.filename}
  width={320}
  height={180}
/>

Self-Hosted Setup

Point to your self-hosted instance:

// lib/playvideo.ts
import PlayVideo from '@playvideo/playvideo-sdk';

export const bb = new PlayVideo({
  apiKey: process.env.BLOCKBUSTER_API_KEY!,
  baseUrl: process.env.BLOCKBUSTER_URL || 'https://api.playvideo.dev'
});
# .env.local
BLOCKBUSTER_API_KEY=your-key
BLOCKBUSTER_URL=https://video.yourdomain.com