使用 Next.js、Clerk 和 TailwindCSS 构建 YouTube Live 克隆 - 第二部分

发布: (2025年12月13日 GMT+8 02:00)
6 min read
原文: Dev.to

Source: Dev.to

概览

在本系列的第一部分中,我们已经搭建了 YouTube Live 克隆的基础:

  • 为数据库配置了 Prisma
  • 使用 Clerk 添加了身份验证
  • 将 Stream 集成到应用中
  • 构建了首页

在第二部分中,我们将使用 Stream 的 React Video SDK 创建 创建观看 直播的页面,并使用 React Chat SDK 添加实时聊天功能。

你可以查看线上演示以及在 GitHub 上的完整源码。

构建互动系统(点赞/点踩)

我们将实现一个类似 YouTube 的互动系统,让观众能够对直播进行点赞或点踩。这需要一个自定义 Hook 来管理前端状态,以及一个 API 路由通过 Prisma 持久化数据。

构建 useReactions Hook

useReactions Hook 用于获取初始计数、处理乐观 UI 更新,并与后端同步。

创建 hooks/useReactions.tsx

'use client';
import { useEffect, useState } from 'react';

type MyReaction = 'LIKE' | 'DISLIKE' | null;

export function useReactions(livestreamId: string) {
  const [likes, setLikes] = useState(0);
  const [dislikes, setDislikes] = useState(0);
  const [myReaction, setMyReaction] = useState<MyReaction>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let alive = true;
    (async () => {
      const res = await fetch(`/api/livestreams/${livestreamId}/reactions`, {
        cache: 'no-store',
      });
      const data = await res.json();
      if (!alive) return;
      setLikes(data.likes);
      setDislikes(data.dislikes);
      setMyReaction(data.myReaction);
      setLoading(false);
    })();
    return () => {
      alive = false;
    };
  }, [livestreamId]);

  const send = async (type: 'LIKE' | 'DISLIKE') => {
    // optimistic update
    const prev = { likes, dislikes, myReaction };
    if (type === 'LIKE') {
      if (myReaction === 'LIKE') {
        setLikes(likes - 1);
        setMyReaction(null);
      } else if (myReaction === 'DISLIKE') {
        setDislikes(dislikes - 1);
        setLikes(likes + 1);
        setMyReaction('LIKE');
      } else {
        setLikes(likes + 1);
        setMyReaction('LIKE');
      }
    } else {
      if (myReaction === 'DISLIKE') {
        setDislikes(dislikes - 1);
        setMyReaction(null);
      } else if (myReaction === 'LIKE') {
        setLikes(likes - 1);
        setDislikes(dislikes + 1);
        setMyReaction('DISLIKE');
      } else {
        setDislikes(dislikes + 1);
        setMyReaction('DISLIKE');
      }
    }

    try {
      const res = await fetch(`/api/livestreams/${livestreamId}/reactions`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ type }),
      });
      if (!res.ok) throw new Error('Failed');
      const data = await res.json();
      setLikes(data.likes);
      setDislikes(data.dislikes);
      setMyReaction(data.myReaction);
    } catch {
      // revert on error
      setLikes(prev.likes);
      setDislikes(prev.dislikes);
      setMyReaction(prev.myReaction);
    }
  };

  return {
    likes,
    dislikes,
    myReaction,
    like: () => send('LIKE'),
    dislike: () => send('DISLIKE'),
    loading,
  };
}

关键点

  • useEffect 在组件挂载时获取初始的互动计数以及当前用户的互动状态。
  • send 函数在 API 请求完成前立即更新本地状态(乐观 UI)。
  • 若请求失败,catch 块会回滚到之前的状态,保持 UI 与数据库一致。

创建互动 API 路由

创建 app/api/livestreams/[livestreamId]/reactions/route.ts

import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import prisma from '@/lib/prisma';

export async function GET(
  _req: Request,
  { params }: { params: Promise<any> }
) {
  const { userId } = await auth();
  const { livestreamId } = await params;

  const [likes, dislikes, mine] = await Promise.all([
    prisma.reaction.count({ where: { livestreamId, type: 'LIKE' } }),
    prisma.reaction.count({ where: { livestreamId, type: 'DISLIKE' } }),
    userId
      ? prisma.reaction.findUnique({
          where: { userId_livestreamId: { userId, livestreamId } },
          select: { type: true },
        })
      : null,
  ]);

  return NextResponse.json({
    likes,
    dislikes,
    myReaction: mine?.type ?? null,
  });
}

export async function POST(
  req: Request,
  { params }: { params: Promise<any> }
) {
  const { userId } = await auth();
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { livestreamId } = await params;
  const { type } = (await req.json()) as { type: 'LIKE' | 'DISLIKE' };

  if (type !== 'LIKE' && type !== 'DISLIKE') {
    return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
  }

  // Toggle or upsert reaction
  const existing = await prisma.reaction.findUnique({
    where: { userId_livestreamId: { userId, livestreamId } },
  });

  if (existing && existing.type === type) {
    await prisma.reaction.delete({
      where: { userId_livestreamId: { userId, livestreamId } },
    });
  } else {
    await prisma.reaction.upsert({
      where: { userId_livestreamId: { userId, livestreamId } },
      create: { userId, livestreamId, type },
      update: { type },
    });
  }

  // Return fresh counts and current reaction
  const [likes, dislikes, mine] = await Promise.all([
    prisma.reaction.count({ where: { livestreamId, type: 'LIKE' } }),
    prisma.reaction.count({ where: { livestreamId, type: 'DISLIKE' } }),
    prisma.reaction.findUnique({
      where: { userId_livestreamId: { userId, livestreamId } },
      select: { type: true },
    }),
  ]);

  return NextResponse.json({
    likes,
    dislikes,
    myReaction: mine?.type ?? null,
  });
}

该路由提供:

  • GET – 返回总点赞、点踩数以及已认证用户当前的互动状态(或 null)。
  • POST – 切换或更新互动,并返回最新的计数。

构建直播工作室

直播工作室是主播的指挥中心:创建流、获取 OBS 密钥以及管理直播。

创建直播布局

布局会初始化 Stream 通话,并在用户进入工作室时确保聊天频道已准备就绪。

创建 app/(home)/livestreaming/layout.tsx

'use client';
import { ReactNode, useEffect, useState } from 'react';
import {
  Call,
  StreamCall,
  useStreamVideoClient,
} from '@stream-io/video-react-sdk';
import { nanoid } from 'nanoid';
import { useUser } from '@clerk/nextjs';
import { useChatContext } from 'stream-chat-react';

import Spinner from '@/components/Spinner';
import { joinCall } from '@/lib/utils';

interface LivestreamLayoutProps {
  children: ReactNode;
}

const LivestreamLayout = ({ children }: LivestreamLayoutProps) => {
  // implementation details...
  return (
    <>
      {/* layout markup */}
      {children}
    </>
  );
};

export default LivestreamLayout;

(完整实现请参考仓库,以上代码片段仅展示文件结构和导入。)

Back to Blog

相关文章

阅读更多 »