Skip to content

Commit

Permalink
feat: ✨ Stream live feature.
Browse files Browse the repository at this point in the history
- Introduce livekit to the project.
- Create livekit webhook implementation.
- Link it with API Key and URL.
- Test it on OBS.
- Implement live streaming.
- Create video component and dependency components for it.
- Add video component to stream player.
- Create custom controls for video.
- Create full screen control, live-video, loading-video, offline-video, volume-control.

Users can now connect to their favorite streaming/recording software and go live on S3MER platform.
  • Loading branch information
RicardoGEsteves committed Dec 16, 2023
1 parent 2e20670 commit 09e988f
Show file tree
Hide file tree
Showing 17 changed files with 445 additions and 83 deletions.
25 changes: 6 additions & 19 deletions app/(browse)/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { notFound } from "next/navigation";
import { getUserByUsername } from "@/lib/user-service";
import { isFollowingUser } from "@/lib/follow-service";
import { isBlockedByUser } from "@/lib/block-service";
// import StreamPlayer from "@/components/stream-player";
import Actions from "./_components/actions";
import StreamPlayer from "@/components/stream-player";

interface UserPageProps {
params: {
Expand All @@ -27,23 +26,11 @@ const UserPage = async ({ params }: UserPageProps) => {
}

return (
// <StreamPlayer
// user={user}
// stream={user.stream}
// isFollowing={isFollowing}
// />

// TODO: Remove this
<div className="flex flex-col gap-y4">
<p>Username: {user.username}</p>
<p>user ID: {user.id} </p>
<p>is following: {`${isFollowing}`} </p>
<p>is blocked: {`${isBlocked}`} </p>
<Actions
isFollowing={isFollowing}
userId={user.id}
/>
</div>
<StreamPlayer
user={user}
stream={user.stream}
isFollowing={isFollowing}
/>
);
};

Expand Down
2 changes: 1 addition & 1 deletion app/(browse)/_components/navbar/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const Actions = async () => {
>
<Link href={`/u/${user.username}`}>
<AppWindowIcon className="h-5 w-5 lg:mr-2" />
<span className="hidden lg:block">Dashboard</span>
<span className="hidden lg:block uppercase">Dashboard</span>
</Link>
</Button>
<UserButton afterSignOutUrl="/" />
Expand Down
4 changes: 3 additions & 1 deletion app/(browse)/_components/sidebar/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ const Toggle = () => {
)}
{!collapsed && (
<div className="p-3 pl-6 mb-2 flex items-center w-full">
<p className="font-semibold text-foreground">Made for you</p>
<p className="font-semibold text-foreground uppercase">
Made for you
</p>
<Hint
label={label}
side="right"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const Toggle = () => {
)}
{!collapsed && (
<div className="p-3 pl-6 mb-2 hidden lg:flex items-center w-full">
<p className="font-semibold text-foreground">Dashboard</p>
<p className="font-semibold text-foreground uppercase">Dashboard</p>
<Hint
label={label}
side="right"
Expand Down
7 changes: 7 additions & 0 deletions app/api/webhooks/livekit/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { WebhookReceiver } from "livekit-server-sdk";

import { db } from "@/lib/db";
Expand All @@ -16,6 +17,10 @@ export async function POST(req: Request) {
if (!authorization) {
return new Response("No authorization header", { status: 400 });
}
//TODO: check if needed
// if (!authorization || authorization !== process.env.LIVEKIT_WEBHOOK_SECRET) {
// return NextResponse.json("Unauthorized", { status: 400 });
// }

const event = receiver.receive(body, authorization);

Expand All @@ -40,4 +45,6 @@ export async function POST(req: Request) {
},
});
}
//TODO: check if needed
// return NextResponse.json("OK", { status: 200 });
}
40 changes: 40 additions & 0 deletions components/stream-player/fullscreen-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import { Maximize, Minimize } from "lucide-react";

import Hint from "@/components/hint";
import { Button } from "../ui/button";

interface FullscreenControlProps {
isFullscreen: boolean;
onToggle: () => void;
}

const FullscreenControl = ({
isFullscreen,
onToggle,
}: FullscreenControlProps) => {
const Icon = isFullscreen ? Minimize : Maximize;

const label = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";

return (
<div className="flex items-center justify-center gap-4">
<Hint
label={label}
asChild
>
<Button
variant="ghost"
size="icon"
onClick={onToggle}
className="text-primary p-1.5 hover:bg-foreground/10 rounded-lg"
>
<Icon className="h-5 w-5" />
</Button>
</Hint>
</div>
);
};

export default FullscreenControl;
119 changes: 58 additions & 61 deletions components/stream-player/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { Stream, User } from "@prisma/client";
import { LiveKitRoom } from "@livekit/components-react";

import { cn } from "@/lib/utils";
// import { useChatSidebar } from "@/store/use-chat-sidebar";
import { useChatSidebar } from "@/store/use-chat-sidebar";
import { useViewerToken } from "@/hooks/use-viewer-token";

// import { InfoCard } from "./info-card";
// import { AboutCard } from "./about-card";
// import { ChatToggle } from "./chat-toggle";
// import { Chat, ChatSkeleton } from "./chat";
// import { Video, VideoSkeleton } from "./video";
import Video, { VideoSkeleton } from "./video";
// import { Header, HeaderSkeleton } from "./header";

type CustomStream = {
Expand Down Expand Up @@ -41,70 +41,67 @@ interface StreamPlayerProps {

const StreamPlayer = ({ user, stream, isFollowing }: StreamPlayerProps) => {
const { token, name, identity } = useViewerToken(user.id);
// const { collapsed } = useChatSidebar((state) => state);
const { collapsed } = useChatSidebar((state) => state);

if (!token || !name || !identity) {
return <StreamPlayerSkeleton />;
}

return (
// <>
// {collapsed && (
// <div className="hidden lg:block fixed top-[100px] right-2 z-50">
// <ChatToggle />
// </div>
// )}
// <LiveKitRoom
// token={token}
// serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_WS_URL}
// className={cn(
// "grid grid-cols-1 lg:gap-y-0 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-6 h-full",
// collapsed && "lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2"
// )}
// >
// <div className="space-y-4 col-span-1 lg:col-span-2 xl:col-span-2 2xl:col-span-5 lg:overflow-y-auto hidden-scrollbar pb-10">
// <Video
// hostName={user.username}
// hostIdentity={user.id}
// />
// <Header
// hostName={user.username}
// hostIdentity={user.id}
// viewerIdentity={identity}
// imageUrl={user.imageUrl}
// isFollowing={isFollowing}
// name={stream.name}
// />
// <InfoCard
// hostIdentity={user.id}
// viewerIdentity={identity}
// name={stream.name}
// thumbnailUrl={stream.thumbnailUrl}
// />
// <AboutCard
// hostName={user.username}
// hostIdentity={user.id}
// viewerIdentity={identity}
// bio={user.bio}
// followedByCount={user._count.followedBy}
// />
// </div>
// <div className={cn("col-span-1", collapsed && "hidden")}>
// <Chat
// viewerName={name}
// hostName={user.username}
// hostIdentity={user.id}
// isFollowing={isFollowing}
// isChatEnabled={stream.isChatEnabled}
// isChatDelayed={stream.isChatDelayed}
// isChatFollowersOnly={stream.isChatFollowersOnly}
// />
// </div>
// </LiveKitRoom>
// </>

// TODO: Remove this
<>STREAM PLAYER</>
<>
{/* {collapsed && (
<div className="hidden lg:block fixed top-[100px] right-2 z-50">
<ChatToggle />
</div>
)} */}
<LiveKitRoom
token={token}
serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_WS_URL}
className={cn(
"grid grid-cols-1 lg:gap-y-0 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-6 h-full",
collapsed && "lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2"
)}
>
<div className="space-y-4 col-span-1 lg:col-span-2 xl:col-span-2 2xl:col-span-5 lg:overflow-y-auto hidden-scrollbar pb-10">
<Video
hostName={user.username}
hostIdentity={user.id}
/>
{/* <Header
hostName={user.username}
hostIdentity={user.id}
viewerIdentity={identity}
imageUrl={user.imageUrl}
isFollowing={isFollowing}
name={stream.name}
/> */}
{/* <InfoCard
hostIdentity={user.id}
viewerIdentity={identity}
name={stream.name}
thumbnailUrl={stream.thumbnailUrl}
/> */}
{/* <AboutCard
hostName={user.username}
hostIdentity={user.id}
viewerIdentity={identity}
bio={user.bio}
followedByCount={user._count.followedBy}
/> */}
</div>
{/* <div className={cn("col-span-1", collapsed && "hidden")}>
<Chat
viewerName={name}
hostName={user.username}
hostIdentity={user.id}
isFollowing={isFollowing}
isChatEnabled={stream.isChatEnabled}
isChatDelayed={stream.isChatDelayed}
isChatFollowersOnly={stream.isChatFollowersOnly}
/>
</div> */}
</LiveKitRoom>
</>
);
};

Expand All @@ -114,7 +111,7 @@ export const StreamPlayerSkeleton = () => {
return (
<div className="grid grid-cols-1 lg:gap-y-0 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-6 h-full">
<div className="space-y-4 col-span-1 lg:col-span-2 xl:col-span-2 2xl:col-span-5 lg:overflow-y-auto hidden-scrollbar pb-10">
{/* <VideoSkeleton /> */}
<VideoSkeleton />
{/* <HeaderSkeleton /> */}
</div>
<div className="col-span-1 bg-background">{/* <ChatSkeleton /> */}</div>
Expand Down
94 changes: 94 additions & 0 deletions components/stream-player/live-video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"use client";

import { useRef, useState, useEffect } from "react";
import { Participant, Track } from "livekit-client";
import { useTracks } from "@livekit/components-react";
import { useEventListener } from "usehooks-ts";

import VolumeControl from "./volume-control";
import FullscreenControl from "./fullscreen-control";

interface LiveVideoProps {
participant: Participant;
}

const LiveVideo = ({ participant }: LiveVideoProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);

const [isFullscreen, setIsFullscreen] = useState(false);
const [volume, setVolume] = useState(0);

const onVolumeChange = (value: number) => {
setVolume(+value);
if (videoRef?.current) {
videoRef.current.muted = value === 0;
videoRef.current.volume = +value * 0.01;
}
};

const toggleMute = () => {
const isMuted = volume === 0;

setVolume(isMuted ? 50 : 0);

if (videoRef?.current) {
videoRef.current.muted = !isMuted;
videoRef.current.volume = isMuted ? 0.5 : 0;
}
};

useEffect(() => {
onVolumeChange(0);
}, []);

const toggleFullscreen = () => {
if (isFullscreen) {
document.exitFullscreen();
} else if (wrapperRef?.current) {
wrapperRef.current.requestFullscreen();
}
};

const handleFullscreenChange = () => {
const isCurrentlyFullscreen = document.fullscreenElement !== null;
setIsFullscreen(isCurrentlyFullscreen);
};

useEventListener("fullscreenchange", handleFullscreenChange, wrapperRef);

useTracks([Track.Source.Camera, Track.Source.Microphone])
.filter((track) => track.participant.identity === participant.identity)
.forEach((track) => {
if (videoRef.current) {
track.publication.track?.attach(videoRef.current);
}
});

return (
<div
ref={wrapperRef}
className="relative h-full flex"
>
<video
ref={videoRef}
width="100%"
/>
<div className="absolute top-0 h-full w-full opacity-0 hover:opacity-100 hover:transition-all">
<div className="absolute bottom-0 flex h-10 w-full items-center justify-between bg-background/70 px-4">
<VolumeControl
onChange={onVolumeChange}
value={volume}
onToggle={toggleMute}
/>
<FullscreenControl
isFullscreen={isFullscreen}
onToggle={toggleFullscreen}
/>
</div>
</div>
</div>
);
};

export default LiveVideo;
16 changes: 16 additions & 0 deletions components/stream-player/loading-video.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Loader } from "lucide-react";

interface LoadingVideoProps {
label: string;
}

const LoadingVideo = ({ label }: LoadingVideoProps) => {
return (
<div className="h-full flex flex-col space-y-4 justify-center items-center">
<Loader className="h-10 w-10 text-muted-foreground animate-spin" />
<p className="text-muted-foreground capitalize">{label}</p>
</div>
);
};

export default LoadingVideo;
Loading

0 comments on commit 09e988f

Please sign in to comment.