使用 Next.js、Clerk 和 TailwindCSS 构建 YouTube Live 克隆 - 第二部分
Source: Dev.to
概览
在本系列的第一部分中,我们已经搭建了 YouTube Live 克隆的基础:
- 为数据库配置了 Prisma
- 使用 Clerk 添加了身份验证
- 将 Stream 集成到应用中
- 构建了首页
在第二部分中,我们将使用 Stream 的 React Video SDK 创建 创建 和 观看 直播的页面,并使用 React Chat SDK 添加实时聊天功能。
构建互动系统(点赞/点踩)
我们将实现一个类似 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;
(完整实现请参考仓库,以上代码片段仅展示文件结构和导入。)