Documentation Next.js

はじめに

この記事では、Next.jsを使用して本格的な動画配信サービスを構築する方法を解説します。Netflix、YouTube、Huluのような動画配信プラットフォームの基盤となる技術を学び、実際に動作するコードを通じて理解を深めていきます。

この記事で学べること

  • ストリーミング技術の基礎: HLS/DASHプロトコルの仕組みと違い
  • 動画プレイヤーの実装: video.jsを使った高機能プレイヤーの構築
  • クラウドインフラの活用: AWS Media ServicesとCloudFrontの設定
  • セキュリティ対策: 署名付きURLとDRMによるコンテンツ保護

ストリーミング技術の基礎

プログレッシブダウンロードとストリーミングの違い

動画配信には主に2つの方式があります。

方式特徴ユースケース
プログレッシブダウンロード動画ファイルを先頭から順次ダウンロード短い動画、シンプルな実装
ストリーミング動画を小さなセグメントに分割して配信長時間の動画、ライブ配信

HLS(HTTP Live Streaming)とは

HLSはAppleが開発したストリーミングプロトコルです。動画を数秒単位のセグメント(.tsファイル)に分割し、マニフェストファイル(.m3u8)で管理します。

# HLSの構成例
video/
├── playlist.m3u8          # マスタープレイリスト
├── 720p/
│   ├── playlist.m3u8      # 720p用プレイリスト
│   ├── segment_001.ts
│   ├── segment_002.ts
│   └── ...
├── 480p/
│   ├── playlist.m3u8      # 480p用プレイリスト
│   └── ...
└── 360p/
    └── ...

DASH(Dynamic Adaptive Streaming over HTTP)とは

DASHは国際標準規格(ISO/IEC 23009-1)として策定されたストリーミングプロトコルです。マニフェストファイルには.mpd(Media Presentation Description)形式を使用します。

項目HLSDASH
開発元AppleMPEG
マニフェスト.m3u8.mpd
セグメント形式.ts, .fmp4.m4s, .mp4
DRM対応FairPlayWidevine, PlayReady
iOS対応ネイティブ要対応

プロジェクトのセットアップ

必要なパッケージのインストール

# Next.jsプロジェクトの作成
npx create-next-app@latest video-streaming-app --typescript --tailwind --app

# 動画プレイヤー関連のパッケージ
cd video-streaming-app
npm install video.js @types/video.js
npm install hls.js

# AWS SDK(動画管理用)
npm install @aws-sdk/client-s3 @aws-sdk/client-cloudfront
npm install @aws-sdk/cloudfront-signer

プロジェクト構成

video-streaming-app/
├── src/
│   ├── app/
│   │   ├── page.tsx
│   │   ├── videos/
│   │   │   └── [id]/
│   │   │       └── page.tsx
│   │   └── api/
│   │       └── videos/
│   │           └── [id]/
│   │               └── route.ts
│   ├── components/
│   │   └── VideoPlayer.tsx
│   ├── lib/
│   │   ├── aws.ts
│   │   └── video.ts
│   └── types/
│       └── video.ts
└── public/
    └── videos/

動画プレイヤーの実装

video.jsを使った基本的なプレイヤー

まず、video.jsを使用した再利用可能なプレイヤーコンポーネントを作成します。

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

import { useEffect, useRef, useState } from 'react';
import videojs from 'video.js';
import Player from 'video.js/dist/types/player';
import 'video.js/dist/video-js.css';

// video.jsのプレイヤーオプションの型定義
interface VideoPlayerProps {
  src: string;
  type?: 'application/x-mpegURL' | 'application/dash+xml' | 'video/mp4';
  poster?: string;
  autoplay?: boolean;
  onReady?: (player: Player) => void;
  onTimeUpdate?: (currentTime: number) => void;
  onEnded?: () => void;
}

export default function VideoPlayer({
  src,
  type = 'application/x-mpegURL', // デフォルトはHLS
  poster,
  autoplay = false,
  onReady,
  onTimeUpdate,
  onEnded,
}: VideoPlayerProps) {
  const videoRef = useRef<HTMLDivElement>(null);
  const playerRef = useRef<Player | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // プレイヤーが既に初期化されている場合はスキップ
    if (!videoRef.current || playerRef.current) return;

    // video要素を動的に作成
    const videoElement = document.createElement('video-js');
    videoElement.classList.add('vjs-big-play-centered', 'vjs-16-9');
    videoRef.current.appendChild(videoElement);

    // video.jsプレイヤーの初期化
    const player = videojs(videoElement, {
      controls: true,              // コントロールバーを表示
      responsive: true,            // レスポンシブ対応
      fluid: true,                 // 親要素に合わせてサイズ調整
      autoplay: autoplay,
      preload: 'auto',             // 動画のプリロード
      poster: poster,              // サムネイル画像
      playbackRates: [0.5, 1, 1.25, 1.5, 2], // 再生速度オプション
      html5: {
        vhs: {
          // HLS.jsの設定
          overrideNative: true,    // ネイティブHLSを上書き
          enableLowInitialPlaylist: true, // 低帯域から開始
        },
      },
      sources: [{ src, type }],
    });

    playerRef.current = player;

    // イベントリスナーの設定
    player.on('ready', () => {
      setIsLoading(false);
      console.log('Video player is ready');
      onReady?.(player);
    });

    player.on('timeupdate', () => {
      const currentTime = player.currentTime();
      if (currentTime !== undefined) {
        onTimeUpdate?.(currentTime);
      }
    });

    player.on('ended', () => {
      console.log('Video playback ended');
      onEnded?.();
    });

    player.on('error', () => {
      const error = player.error();
      setError(error?.message || '動画の読み込みに失敗しました');
      setIsLoading(false);
    });

    // クリーンアップ
    return () => {
      if (playerRef.current) {
        playerRef.current.dispose();
        playerRef.current = null;
      }
    };
  }, [src, type, poster, autoplay, onReady, onTimeUpdate, onEnded]);

  // ソースが変更された場合の処理
  useEffect(() => {
    const player = playerRef.current;
    if (player && !player.isDisposed()) {
      player.src({ src, type });
    }
  }, [src, type]);

  if (error) {
    return (
      <div className="bg-gray-900 rounded-lg p-8 text-center">
        <p className="text-red-500">{error}</p>
        <button
          onClick={() => window.location.reload()}
          className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
        >
          再読み込み
        </button>
      </div>
    );
  }

  return (
    <div className="relative">
      {isLoading && (
        <div className="absolute inset-0 flex items-center justify-center bg-gray-900 rounded-lg">
          <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
        </div>
      )}
      <div ref={videoRef} className="rounded-lg overflow-hidden" />
    </div>
  );
}

HLS.jsを直接使用する実装

より細かい制御が必要な場合は、HLS.jsを直接使用します。

// src/components/HLSPlayer.tsx
'use client';

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

interface HLSPlayerProps {
  src: string;
  poster?: string;
}

interface QualityLevel {
  height: number;
  bitrate: number;
}

export default function HLSPlayer({ src, poster }: HLSPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const hlsRef = useRef<Hls | null>(null);
  const [qualityLevels, setQualityLevels] = useState<QualityLevel[]>([]);
  const [currentQuality, setCurrentQuality] = useState<number>(-1); // -1 = auto

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

    // HLS.jsがサポートされているか確認
    if (Hls.isSupported()) {
      const hls = new Hls({
        // バッファ設定
        maxBufferLength: 30,           // 最大バッファ長(秒)
        maxMaxBufferLength: 60,        // 絶対最大バッファ長
        // 再試行設定
        manifestLoadingMaxRetry: 3,    // マニフェスト読み込みリトライ回数
        levelLoadingMaxRetry: 4,       // レベル読み込みリトライ回数
        fragLoadingMaxRetry: 6,        // フラグメント読み込みリトライ回数
        // 品質切り替え設定
        startLevel: -1,                // -1 = 自動選択
        abrEwmaDefaultEstimate: 500000, // 初期帯域推定値(500kbps)
      });

      hlsRef.current = hls;
      hls.loadSource(src);
      hls.attachMedia(video);

      // マニフェスト解析完了時のイベント
      hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
        console.log(`${data.levels.length}種類の品質レベルが利用可能`);

        // 利用可能な品質レベルを取得
        const levels = data.levels.map((level) => ({
          height: level.height,
          bitrate: level.bitrate,
        }));
        setQualityLevels(levels);
      });

      // 品質レベル切り替え時のイベント
      hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => {
        console.log(`品質レベルが${data.level}に切り替わりました`);
        setCurrentQuality(data.level);
      });

      // エラーハンドリング
      hls.on(Hls.Events.ERROR, (event, data) => {
        if (data.fatal) {
          switch (data.type) {
            case Hls.ErrorTypes.NETWORK_ERROR:
              console.error('ネットワークエラー、リカバリーを試行...');
              hls.startLoad();
              break;
            case Hls.ErrorTypes.MEDIA_ERROR:
              console.error('メディアエラー、リカバリーを試行...');
              hls.recoverMediaError();
              break;
            default:
              console.error('致命的なエラー、再初期化が必要');
              hls.destroy();
              break;
          }
        }
      });

      return () => {
        hls.destroy();
        hlsRef.current = null;
      };
    }
    // Safari等、ネイティブHLSをサポートするブラウザ
    else if (video.canPlayType('application/vnd.apple.mpegurl')) {
      video.src = src;
    }
  }, [src]);

  // 品質レベルの手動切り替え
  const handleQualityChange = (level: number) => {
    if (hlsRef.current) {
      hlsRef.current.currentLevel = level; // -1 で自動に戻す
    }
  };

  return (
    <div className="relative">
      <video
        ref={videoRef}
        poster={poster}
        controls
        className="w-full rounded-lg"
        playsInline // モバイル対応
      />

      {/* 品質選択UI */}
      {qualityLevels.length > 0 && (
        <div className="absolute bottom-16 right-4 bg-black/80 rounded p-2">
          <select
            value={currentQuality}
            onChange={(e) => handleQualityChange(Number(e.target.value))}
            className="bg-transparent text-white text-sm"
          >
            <option value={-1}>自動</option>
            {qualityLevels.map((level, index) => (
              <option key={index} value={index}>
                {level.height}p ({Math.round(level.bitrate / 1000)}kbps)
              </option>
            ))}
          </select>
        </div>
      )}
    </div>
  );
}

動画一覧ページの実装

型定義

// src/types/video.ts
export interface Video {
  id: string;
  title: string;
  description: string;
  thumbnailUrl: string;
  duration: number; // 秒
  hlsUrl: string;
  dashUrl?: string;
  createdAt: Date;
  views: number;
  category: string;
}

export interface VideoListResponse {
  videos: Video[];
  totalCount: number;
  hasMore: boolean;
  nextCursor?: string;
}

動画一覧ページ

// src/app/page.tsx
import Link from 'next/link';
import Image from 'next/image';
import { Video } from '@/types/video';

// サンプルデータ(実際はAPIから取得)
const sampleVideos: Video[] = [
  {
    id: '1',
    title: 'Next.js入門講座',
    description: 'Next.jsの基本を学びましょう',
    thumbnailUrl: '/thumbnails/nextjs-intro.jpg',
    duration: 1800, // 30分
    hlsUrl: 'https://example.com/videos/1/playlist.m3u8',
    createdAt: new Date('2024-01-15'),
    views: 15000,
    category: 'プログラミング',
  },
  // ... 他の動画
];

// 秒数を "MM:SS" 形式に変換するユーティリティ関数
function formatDuration(seconds: number): string {
  const minutes = Math.floor(seconds / 60);
  const remainingSeconds = seconds % 60;
  return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}

// 視聴回数をフォーマットする関数(1000以上は "1.5K" のように表示)
function formatViews(views: number): string {
  if (views >= 1000000) {
    return `${(views / 1000000).toFixed(1)}M`;
  }
  if (views >= 1000) {
    return `${(views / 1000).toFixed(1)}K`;
  }
  return views.toString();
}

export default function HomePage() {
  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">動画一覧</h1>

      {/* カテゴリーフィルター */}
      <div className="flex gap-4 mb-8 overflow-x-auto pb-2">
        {['すべて', 'プログラミング', 'デザイン', 'ビジネス'].map((category) => (
          <button
            key={category}
            className="px-4 py-2 bg-gray-800 text-white rounded-full hover:bg-gray-700 whitespace-nowrap"
          >
            {category}
          </button>
        ))}
      </div>

      {/* 動画グリッド */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
        {sampleVideos.map((video) => (
          <Link
            key={video.id}
            href={`/videos/${video.id}`}
            className="group block"
          >
            {/* サムネイル */}
            <div className="relative aspect-video bg-gray-800 rounded-lg overflow-hidden">
              <Image
                src={video.thumbnailUrl}
                alt={video.title}
                fill
                className="object-cover group-hover:scale-105 transition-transform duration-300"
              />
              {/* 再生時間バッジ */}
              <span className="absolute bottom-2 right-2 bg-black/80 text-white text-xs px-2 py-1 rounded">
                {formatDuration(video.duration)}
              </span>
            </div>

            {/* 動画情報 */}
            <div className="mt-3">
              <h2 className="font-semibold line-clamp-2 group-hover:text-blue-400 transition-colors">
                {video.title}
              </h2>
              <p className="text-sm text-gray-400 mt-1">
                {formatViews(video.views)}回視聴 ・ {video.category}
              </p>
            </div>
          </Link>
        ))}
      </div>
    </main>
  );
}

動画詳細ページ

// src/app/videos/[id]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import VideoPlayer from '@/components/VideoPlayer';
import { Video } from '@/types/video';

// 動画データを取得する関数(実際はデータベースやAPIから取得)
async function getVideo(id: string): Promise<Video | null> {
  // 実際の実装ではデータベースやAPIから取得
  // ここではサンプルとして固定データを返す
  const videos: Record<string, Video> = {
    '1': {
      id: '1',
      title: 'Next.js入門講座',
      description: 'Next.jsの基本的な使い方を学びます。App Router、Server Components、データフェッチングなど、最新のNext.js機能を解説します。',
      thumbnailUrl: '/thumbnails/nextjs-intro.jpg',
      duration: 1800,
      hlsUrl: 'https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8', // サンプルHLS
      createdAt: new Date('2024-01-15'),
      views: 15000,
      category: 'プログラミング',
    },
  };

  return videos[id] || null;
}

// メタデータの生成
export async function generateMetadata({ params }: { params: { id: string } }) {
  const video = await getVideo(params.id);

  if (!video) {
    return { title: '動画が見つかりません' };
  }

  return {
    title: video.title,
    description: video.description,
    openGraph: {
      title: video.title,
      description: video.description,
      images: [video.thumbnailUrl],
      type: 'video.other',
    },
  };
}

// ローディングスケルトン
function VideoSkeleton() {
  return (
    <div className="animate-pulse">
      <div className="aspect-video bg-gray-800 rounded-lg" />
      <div className="mt-4 h-8 bg-gray-800 rounded w-3/4" />
      <div className="mt-2 h-4 bg-gray-800 rounded w-1/2" />
    </div>
  );
}

export default async function VideoPage({ params }: { params: { id: string } }) {
  const video = await getVideo(params.id);

  if (!video) {
    notFound();
  }

  return (
    <main className="container mx-auto px-4 py-8">
      <div className="max-w-5xl mx-auto">
        {/* 動画プレイヤー */}
        <Suspense fallback={<VideoSkeleton />}>
          <VideoPlayer
            src={video.hlsUrl}
            type="application/x-mpegURL"
            poster={video.thumbnailUrl}
          />
        </Suspense>

        {/* 動画情報 */}
        <div className="mt-6">
          <h1 className="text-2xl font-bold">{video.title}</h1>
          <div className="flex items-center gap-4 mt-2 text-gray-400">
            <span>{video.views.toLocaleString()}回視聴</span>
            <span>・</span>
            <span>{video.createdAt.toLocaleDateString('ja-JP')}</span>
          </div>
        </div>

        {/* 説明文 */}
        <div className="mt-6 p-4 bg-gray-800 rounded-lg">
          <p className="whitespace-pre-wrap">{video.description}</p>
        </div>
      </div>
    </main>
  );
}

AWS Media Servicesの活用

AWSサービス構成

動画配信に必要なAWSサービスの構成を説明します。

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│   S3 (入力)     │────▶│ MediaConvert     │────▶│  S3 (出力)      │
│  動画アップロード│     │ トランスコーディング │     │  HLS/DASHファイル│
└─────────────────┘     └──────────────────┘     └────────┬────────┘

                        ┌──────────────────┐              │
                        │   CloudFront     │◀─────────────┘
                        │   CDN配信        │
                        └────────┬─────────┘

                        ┌────────▼─────────┐
                        │   ユーザー       │
                        │   動画視聴       │
                        └──────────────────┘

AWS SDKを使った署名付きURL生成

// src/lib/aws.ts
import {
  CloudFrontClient,
  CreateInvalidationCommand,
} from '@aws-sdk/client-cloudfront';
import { getSignedUrl } from '@aws-sdk/cloudfront-signer';

// CloudFrontの設定
const CLOUDFRONT_DOMAIN = process.env.CLOUDFRONT_DOMAIN!;
const CLOUDFRONT_KEY_PAIR_ID = process.env.CLOUDFRONT_KEY_PAIR_ID!;
const CLOUDFRONT_PRIVATE_KEY = process.env.CLOUDFRONT_PRIVATE_KEY!;

/**
 * CloudFront署名付きURLを生成する
 * 期限付きのアクセスを許可し、不正なアクセスを防止
 */
export function generateSignedUrl(
  path: string,
  expiresInSeconds: number = 3600 // デフォルト1時間
): string {
  const url = `https://${CLOUDFRONT_DOMAIN}/${path}`;
  const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);

  return getSignedUrl({
    url,
    keyPairId: CLOUDFRONT_KEY_PAIR_ID,
    privateKey: CLOUDFRONT_PRIVATE_KEY,
    dateLessThan: expiresAt.toISOString(),
  });
}

/**
 * 動画ファイル用の署名付きURLを生成
 * HLSのマスタープレイリストとすべてのセグメントにアクセス可能なワイルドカードポリシーを使用
 */
export function generateVideoSignedUrl(videoId: string): {
  playlistUrl: string;
  policy: string;
} {
  const basePath = `videos/${videoId}`;
  const playlistPath = `${basePath}/playlist.m3u8`;

  // ワイルドカードポリシーを使用して、関連するすべてのセグメントへのアクセスを許可
  const policyStatement = {
    Statement: [
      {
        Resource: `https://${CLOUDFRONT_DOMAIN}/${basePath}/*`,
        Condition: {
          DateLessThan: {
            'AWS:EpochTime': Math.floor(Date.now() / 1000) + 7200, // 2時間有効
          },
        },
      },
    ],
  };

  const policy = JSON.stringify(policyStatement);
  const playlistUrl = generateSignedUrl(playlistPath, 7200);

  return { playlistUrl, policy };
}

// CloudFrontのキャッシュ無効化
const cloudFrontClient = new CloudFrontClient({
  region: process.env.AWS_REGION || 'ap-northeast-1',
});

export async function invalidateCache(
  distributionId: string,
  paths: string[]
): Promise<void> {
  const command = new CreateInvalidationCommand({
    DistributionId: distributionId,
    InvalidationBatch: {
      CallerReference: Date.now().toString(),
      Paths: {
        Quantity: paths.length,
        Items: paths,
      },
    },
  });

  await cloudFrontClient.send(command);
}

動画API

// src/app/api/videos/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { generateVideoSignedUrl } from '@/lib/aws';

// 動画情報を取得するAPI
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const videoId = params.id;

  // ここで認証チェックを行う(例:JWTトークンの検証)
  const authHeader = request.headers.get('authorization');
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return NextResponse.json(
      { error: '認証が必要です' },
      { status: 401 }
    );
  }

  try {
    // 署名付きURLを生成
    const { playlistUrl } = generateVideoSignedUrl(videoId);

    return NextResponse.json({
      id: videoId,
      playlistUrl,
      expiresIn: 7200, // クライアントに有効期限を通知
    });
  } catch (error) {
    console.error('Error generating signed URL:', error);
    return NextResponse.json(
      { error: '動画URLの生成に失敗しました' },
      { status: 500 }
    );
  }
}

セキュリティ対策

視聴制限の実装

// src/lib/video.ts

interface ViewPermission {
  canView: boolean;
  reason?: string;
}

/**
 * ユーザーが動画を視聴できるか確認する
 */
export async function checkViewPermission(
  userId: string | null,
  videoId: string
): Promise<ViewPermission> {
  // 未認証ユーザーはプレビューのみ許可
  if (!userId) {
    return {
      canView: false,
      reason: 'ログインが必要です',
    };
  }

  // 有料コンテンツの購入確認(実際はDBから取得)
  const hasPurchased = await checkPurchaseStatus(userId, videoId);
  if (!hasPurchased) {
    return {
      canView: false,
      reason: 'この動画を視聴するには購入が必要です',
    };
  }

  // 地域制限のチェック
  const isAllowedRegion = await checkRegionRestriction(videoId);
  if (!isAllowedRegion) {
    return {
      canView: false,
      reason: 'この動画はお住まいの地域ではご利用いただけません',
    };
  }

  return { canView: true };
}

// 購入状態の確認(サンプル実装)
async function checkPurchaseStatus(
  userId: string,
  videoId: string
): Promise<boolean> {
  // 実際はデータベースで確認
  return true;
}

// 地域制限の確認(サンプル実装)
async function checkRegionRestriction(videoId: string): Promise<boolean> {
  // 実際はリクエストのIPアドレスから地域を判定
  return true;
}

DRM(Digital Rights Management)の概要

プレミアムコンテンツを保護するためには、DRMの実装が必要です。主要なDRMシステムには以下があります。

DRMシステム対応プラットフォーム提供元
WidevineChrome, Firefox, AndroidGoogle
FairPlaySafari, iOSApple
PlayReadyEdge, WindowsMicrosoft
// DRM設定の例(video.jsとの統合)
const drmConfig = {
  keySystems: {
    // Widevine
    'com.widevine.alpha': {
      url: 'https://license-server.example.com/widevine',
    },
    // FairPlay
    'com.apple.fps.1_0': {
      certificateUri: 'https://license-server.example.com/fairplay/cert',
      licenseUri: 'https://license-server.example.com/fairplay',
    },
  },
};

パフォーマンス最適化

動画のプリロード戦略

// src/components/VideoPreloader.tsx
'use client';

import { useEffect } from 'react';

interface VideoPreloaderProps {
  videoUrls: string[];
}

/**
 * 次の動画をプリロードしてスムーズな再生を実現
 */
export function VideoPreloader({ videoUrls }: VideoPreloaderProps) {
  useEffect(() => {
    // 接続が遅い場合はプリロードをスキップ
    const connection = (navigator as Navigator & { connection?: { effectiveType: string } }).connection;
    if (connection?.effectiveType === 'slow-2g' || connection?.effectiveType === '2g') {
      return;
    }

    // link要素でプリロード
    videoUrls.forEach((url) => {
      const link = document.createElement('link');
      link.rel = 'prefetch';
      link.href = url;
      link.as = 'fetch';
      document.head.appendChild(link);
    });

    return () => {
      // クリーンアップ
      document.querySelectorAll('link[rel="prefetch"]').forEach((el) => {
        el.remove();
      });
    };
  }, [videoUrls]);

  return null;
}

Intersection Observerを使った遅延読み込み

// src/components/LazyVideoPlayer.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import VideoPlayer from './VideoPlayer';

interface LazyVideoPlayerProps {
  src: string;
  poster: string;
}

export function LazyVideoPlayer({ src, poster }: LazyVideoPlayerProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setIsVisible(true);
            observer.disconnect();
          }
        });
      },
      {
        rootMargin: '100px', // 100px手前で読み込み開始
        threshold: 0.1,
      }
    );

    if (containerRef.current) {
      observer.observe(containerRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div ref={containerRef} className="aspect-video bg-gray-800 rounded-lg">
      {isVisible ? (
        <VideoPlayer src={src} poster={poster} />
      ) : (
        <img
          src={poster}
          alt="Video thumbnail"
          className="w-full h-full object-cover rounded-lg"
        />
      )}
    </div>
  );
}

まとめ

この記事では、Next.jsを使った動画配信サービスの構築方法について解説しました。

学んだ内容

  1. ストリーミング技術: HLSとDASHの仕組みと違い
  2. 動画プレイヤー: video.jsとHLS.jsを使った実装
  3. クラウドインフラ: AWS Media ServicesとCloudFrontの活用
  4. セキュリティ: 署名付きURLとDRMによるコンテンツ保護
  5. パフォーマンス: プリロードと遅延読み込みの最適化

次のステップ

  • ライブストリーミング機能の追加
  • 視聴履歴・レコメンデーション機能
  • 字幕・多言語対応
  • アナリティクス(視聴分析)の実装

動画配信サービスの開発は複雑ですが、適切な技術スタックを選択し、段階的に機能を追加していくことで、高品質なサービスを構築できます。

参考文献

円