├── .eslintrc.json
├── .gitignore
├── README.md
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── prettier.config.js
├── public
├── next.svg
└── vercel.svg
├── src
├── app
│ ├── ClientProvider.tsx
│ ├── CreateMeetingPage.tsx
│ ├── actions.ts
│ ├── error.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── meeting
│ │ └── [id]
│ │ │ ├── MeetingLoginPage.tsx
│ │ │ ├── MeetingPage.tsx
│ │ │ ├── left
│ │ │ └── page.tsx
│ │ │ └── page.tsx
│ ├── meetings
│ │ ├── MyMeetingsPage.tsx
│ │ └── page.tsx
│ └── page.tsx
├── components
│ ├── AudioVolumeIndicator.tsx
│ ├── Button.tsx
│ ├── EndCallButton.tsx
│ ├── FlexibleCallLayout.tsx
│ ├── Navbar.tsx
│ ├── PermissionPrompt.tsx
│ └── RecordingsList.tsx
├── hooks
│ ├── useLoadCall.ts
│ ├── useLoadRecordings.ts
│ └── useStreamCall.ts
├── lib
│ └── utils.ts
└── middleware.ts
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["next/core-web-vitals", "prettier"]
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Full-Stack Next.js 14 Zoom Clone
2 |
3 | This is a fully-fledged video calling application built with [Stream's React Video SDK](https://getstream.io/video/sdk/react/) and Next.js. It includes all features you would expect from a video calling app, like mic & cam controls, roles & permissions, call recordings, screen sharing, picture-in-picture, and more.
4 |
5 | Learn how to build this app from scratch in my YouTube tutorial: https://www.youtube.com/watch?v=BL1ixDaanY8
6 |
7 | 
8 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-zoom-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@clerk/nextjs": "^4.29.9",
13 | "@stream-io/node-sdk": "^0.1.11",
14 | "@stream-io/video-react-sdk": "^0.5.1",
15 | "clsx": "^2.1.0",
16 | "lucide-react": "^0.344.0",
17 | "nanoid": "^5.0.6",
18 | "next": "14.1.2",
19 | "react": "^18",
20 | "react-dom": "^18",
21 | "tailwind-merge": "^2.2.1"
22 | },
23 | "devDependencies": {
24 | "@types/node": "^20",
25 | "@types/react": "^18",
26 | "@types/react-dom": "^18",
27 | "autoprefixer": "^10.0.1",
28 | "eslint": "^8",
29 | "eslint-config-next": "14.1.2",
30 | "eslint-config-prettier": "^9.1.0",
31 | "postcss": "^8",
32 | "prettier": "^3.2.5",
33 | "prettier-plugin-tailwindcss": "^0.5.11",
34 | "tailwindcss": "^3.3.0",
35 | "typescript": "^5"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ["prettier-plugin-tailwindcss"],
3 | };
4 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/ClientProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useUser } from "@clerk/nextjs";
4 | import {
5 | StreamVideo,
6 | StreamVideoClient,
7 | User,
8 | } from "@stream-io/video-react-sdk";
9 | import { Loader2 } from "lucide-react";
10 | import { nanoid } from "nanoid";
11 | import { useEffect, useState } from "react";
12 | import { getToken } from "./actions";
13 |
14 | interface ClientProviderProps {
15 | children: React.ReactNode;
16 | }
17 |
18 | export default function ClientProvider({ children }: ClientProviderProps) {
19 | const videoClient = useInitializeVideoClient();
20 |
21 | if (!videoClient) {
22 | return (
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | return {children};
30 | }
31 |
32 | function useInitializeVideoClient() {
33 | const { user, isLoaded: userLoaded } = useUser();
34 | const [videoClient, setVideoClient] = useState(
35 | null,
36 | );
37 |
38 | useEffect(() => {
39 | if (!userLoaded) return;
40 |
41 | let streamUser: User;
42 |
43 | if (user?.id) {
44 | streamUser = {
45 | id: user.id,
46 | name: user.username || user.id,
47 | image: user.imageUrl,
48 | };
49 | } else {
50 | const id = nanoid();
51 | streamUser = {
52 | id,
53 | type: "guest",
54 | name: `Guest ${id}`,
55 | };
56 | }
57 |
58 | const apiKey = process.env.NEXT_PUBLIC_STREAM_VIDEO_API_KEY;
59 |
60 | if (!apiKey) {
61 | throw new Error("Stream API key not set");
62 | }
63 |
64 | const client = new StreamVideoClient({
65 | apiKey,
66 | user: streamUser,
67 | tokenProvider: user?.id ? getToken : undefined,
68 | });
69 |
70 | setVideoClient(client);
71 |
72 | return () => {
73 | client.disconnectUser();
74 | setVideoClient(null);
75 | };
76 | }, [user?.id, user?.username, user?.imageUrl, userLoaded]);
77 |
78 | return videoClient;
79 | }
80 |
--------------------------------------------------------------------------------
/src/app/CreateMeetingPage.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Button from "@/components/Button";
4 | import { useUser } from "@clerk/nextjs";
5 | import {
6 | Call,
7 | MemberRequest,
8 | useStreamVideoClient,
9 | } from "@stream-io/video-react-sdk";
10 | import { Copy, Loader2 } from "lucide-react";
11 | import Link from "next/link";
12 | import { useState } from "react";
13 | import { getUserIds } from "./actions";
14 |
15 | export default function CreateMeetingPage() {
16 | const [descriptionInput, setDescriptionInput] = useState("");
17 | const [startTimeInput, setStartTimeInput] = useState("");
18 | const [participantsInput, setParticipantsInput] = useState("");
19 |
20 | const [call, setCall] = useState();
21 |
22 | const client = useStreamVideoClient();
23 |
24 | const { user } = useUser();
25 |
26 | async function createMeeting() {
27 | if (!client || !user) {
28 | return;
29 | }
30 |
31 | try {
32 | const id = crypto.randomUUID();
33 |
34 | const callType = participantsInput ? "private-meeting" : "default";
35 |
36 | const call = client.call(callType, id);
37 |
38 | const memberEmails = participantsInput
39 | .split(",")
40 | .map((email) => email.trim());
41 |
42 | const memberIds = await getUserIds(memberEmails);
43 |
44 | const members: MemberRequest[] = memberIds
45 | .map((id) => ({ user_id: id, role: "call_member" }))
46 | .concat({ user_id: user.id, role: "call_member" })
47 | .filter(
48 | (v, i, a) => a.findIndex((v2) => v2.user_id === v.user_id) === i,
49 | );
50 |
51 | const starts_at = new Date(startTimeInput || Date.now()).toISOString();
52 |
53 | await call.getOrCreate({
54 | data: {
55 | starts_at,
56 | members,
57 | custom: { description: descriptionInput },
58 | },
59 | });
60 |
61 | setCall(call);
62 | } catch (error) {
63 | console.error(error);
64 | alert("Something went wrong. Please try again later.");
65 | }
66 | }
67 |
68 | if (!client || !user) {
69 | return ;
70 | }
71 |
72 | return (
73 |
74 |
75 | Welcome {user.username}!
76 |
77 |
78 |
Create a new meeting
79 |
83 |
84 |
88 |
91 |
92 | {call &&
}
93 |
94 | );
95 | }
96 |
97 | interface DescriptionInputProps {
98 | value: string;
99 | onChange: (value: string) => void;
100 | }
101 |
102 | function DescriptionInput({ value, onChange }: DescriptionInputProps) {
103 | const [active, setActive] = useState(false);
104 |
105 | return (
106 |
107 |
Meeting info:
108 |
119 | {active && (
120 |
129 | )}
130 |
131 | );
132 | }
133 |
134 | interface StartTimeInputProps {
135 | value: string;
136 | onChange: (value: string) => void;
137 | }
138 |
139 | function StartTimeInput({ value, onChange }: StartTimeInputProps) {
140 | const [active, setActive] = useState(false);
141 |
142 | const dateTimeLocalNow = new Date(
143 | new Date().getTime() - new Date().getTimezoneOffset() * 60_000,
144 | )
145 | .toISOString()
146 | .slice(0, 16);
147 |
148 | return (
149 |
186 | );
187 | }
188 |
189 | interface ParticipantsInputProps {
190 | value: string;
191 | onChange: (value: string) => void;
192 | }
193 |
194 | function ParticipantsInput({ value, onChange }: ParticipantsInputProps) {
195 | const [active, setActive] = useState(false);
196 |
197 | return (
198 |
199 |
Participants:
200 |
211 |
215 | {active && (
216 |
225 | )}
226 |
227 | );
228 | }
229 |
230 | interface MeetingLinkProps {
231 | call: Call;
232 | }
233 |
234 | function MeetingLink({ call }: MeetingLinkProps) {
235 | const meetingLink = `${process.env.NEXT_PUBLIC_BASE_URL}/meeting/${call.id}`;
236 |
237 | return (
238 |
239 |
240 |
241 | Invitation link:{" "}
242 |
243 | {meetingLink}
244 |
245 |
246 |
255 |
256 |
265 | Send email invitation
266 |
267 |
268 | );
269 | }
270 |
271 | function getMailToLink(
272 | meetingLink: string,
273 | startsAt?: Date,
274 | description?: string,
275 | ) {
276 | const startDateFormatted = startsAt
277 | ? startsAt.toLocaleString("en-US", {
278 | dateStyle: "full",
279 | timeStyle: "short",
280 | })
281 | : undefined;
282 |
283 | const subject =
284 | "Join my meeting" + (startDateFormatted ? ` at ${startDateFormatted}` : "");
285 |
286 | const body =
287 | `Join my meeting at ${meetingLink}.` +
288 | (startDateFormatted
289 | ? `\n\nThe meeting starts at ${startDateFormatted}.`
290 | : "") +
291 | (description ? `\n\nDescription: ${description}` : "");
292 |
293 | return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
294 | }
295 |
--------------------------------------------------------------------------------
/src/app/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { clerkClient, currentUser } from "@clerk/nextjs/server";
4 | import { StreamClient } from "@stream-io/node-sdk";
5 |
6 | export async function getToken() {
7 | const streamApiKey = process.env.NEXT_PUBLIC_STREAM_VIDEO_API_KEY;
8 | const streamApiSecret = process.env.STREAM_VIDEO_API_SECRET;
9 |
10 | if (!streamApiKey || !streamApiSecret) {
11 | throw new Error("Stream API key or secret not set");
12 | }
13 |
14 | const user = await currentUser();
15 |
16 | console.log("Generating token for user: ", user?.id);
17 |
18 | if (!user) {
19 | throw new Error("User not authenticated");
20 | }
21 |
22 | const streamClient = new StreamClient(streamApiKey, streamApiSecret);
23 |
24 | const expirationTime = Math.floor(Date.now() / 1000) + 60 * 60;
25 |
26 | const issuedAt = Math.floor(Date.now() / 1000) - 60;
27 |
28 | const token = streamClient.createToken(user.id, expirationTime, issuedAt);
29 |
30 | console.log("Successfully generated token: ", token);
31 |
32 | return token;
33 | }
34 |
35 | export async function getUserIds(emailAddresses: string[]) {
36 | const response = await clerkClient.users.getUserList({
37 | emailAddress: emailAddresses,
38 | });
39 | return response.map((user) => user.id);
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | export default function ErrorPage() {
4 | return (
5 |
6 |
Error
7 |
Sorry, something went wrong. Please try again later.
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/codinginflow/nextjs-zoom-clone/2b5616f7a82c5a416600bc72ad8f887f97608bdd/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .str-video__participant-details {
6 | color: white;
7 | }
8 |
9 | .str-video__menu-container {
10 | color: white;
11 | }
12 |
13 | .str-video__notification {
14 | color: white;
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from "@/components/Navbar";
2 | import { ClerkProvider } from "@clerk/nextjs";
3 | import "@stream-io/video-react-sdk/dist/css/styles.css";
4 | import type { Metadata } from "next";
5 | import { Inter } from "next/font/google";
6 | import ClientProvider from "./ClientProvider";
7 | import "./globals.css";
8 |
9 | const inter = Inter({ subsets: ["latin"] });
10 |
11 | export const metadata: Metadata = {
12 | title: "Meetings App",
13 | description: "A video calling app built with Next.js & Stream",
14 | };
15 |
16 | export default function RootLayout({
17 | children,
18 | }: Readonly<{
19 | children: React.ReactNode;
20 | }>) {
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/meeting/[id]/MeetingLoginPage.tsx:
--------------------------------------------------------------------------------
1 | import Button, { buttonClassName } from "@/components/Button";
2 | import { cn } from "@/lib/utils";
3 | import { ClerkLoaded, ClerkLoading, SignInButton } from "@clerk/nextjs";
4 | import { Loader2 } from "lucide-react";
5 | import Link from "next/link";
6 |
7 | export default function MeetingLoginPage() {
8 | return (
9 |
10 |
Join meeting
11 |
12 |
13 |
14 |
15 |
19 | Continue as guest
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/meeting/[id]/MeetingPage.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import AudioVolumeIndicator from "@/components/AudioVolumeIndicator";
4 | import Button, { buttonClassName } from "@/components/Button";
5 | import FlexibleCallLayout from "@/components/FlexibleCallLayout";
6 | import PermissionPrompt from "@/components/PermissionPrompt";
7 | import RecordingsList from "@/components/RecordingsList";
8 | import useLoadCall from "@/hooks/useLoadCall";
9 | import useStreamCall from "@/hooks/useStreamCall";
10 | import { useUser } from "@clerk/nextjs";
11 | import {
12 | CallingState,
13 | DeviceSettings,
14 | StreamCall,
15 | StreamTheme,
16 | VideoPreview,
17 | useCallStateHooks,
18 | } from "@stream-io/video-react-sdk";
19 | import { Loader2 } from "lucide-react";
20 | import Link from "next/link";
21 | import { useEffect, useState } from "react";
22 |
23 | interface MeetingPageProps {
24 | id: string;
25 | }
26 |
27 | export default function MeetingPage({ id }: MeetingPageProps) {
28 | const { user, isLoaded: userLoaded } = useUser();
29 |
30 | const { call, callLoading } = useLoadCall(id);
31 |
32 | if (!userLoaded || callLoading) {
33 | return ;
34 | }
35 |
36 | if (!call) {
37 | return Call not found
;
38 | }
39 |
40 | const notAllowedToJoin =
41 | call.type === "private-meeting" &&
42 | (!user || !call.state.members.find((m) => m.user.id === user.id));
43 |
44 | if (notAllowedToJoin) {
45 | return (
46 |
47 | You are not allowed to view this meeting
48 |
49 | );
50 | }
51 |
52 | return (
53 |
54 |
55 |
56 |
57 |
58 | );
59 | }
60 |
61 | function MeetingScreen() {
62 | const call = useStreamCall();
63 |
64 | const { useCallEndedAt, useCallStartsAt } = useCallStateHooks();
65 |
66 | const callEndedAt = useCallEndedAt();
67 | const callStartsAt = useCallStartsAt();
68 |
69 | const [setupComplete, setSetupComplete] = useState(false);
70 |
71 | async function handleSetupComplete() {
72 | call.join();
73 | setSetupComplete(true);
74 | }
75 |
76 | const callIsInFuture = callStartsAt && new Date(callStartsAt) > new Date();
77 |
78 | const callHasEnded = !!callEndedAt;
79 |
80 | if (callHasEnded) {
81 | return ;
82 | }
83 |
84 | if (callIsInFuture) {
85 | return ;
86 | }
87 |
88 | const description = call.state.custom.description;
89 |
90 | return (
91 |
92 | {description && (
93 |
94 | Meeting description: {description}
95 |
96 | )}
97 | {setupComplete ? (
98 |
99 | ) : (
100 |
101 | )}
102 |
103 | );
104 | }
105 |
106 | interface SetupUIProps {
107 | onSetupComplete: () => void;
108 | }
109 |
110 | function SetupUI({ onSetupComplete }: SetupUIProps) {
111 | const call = useStreamCall();
112 |
113 | const { useMicrophoneState, useCameraState } = useCallStateHooks();
114 |
115 | const micState = useMicrophoneState();
116 | const camState = useCameraState();
117 |
118 | const [micCamDisabled, setMicCamDisabled] = useState(false);
119 |
120 | useEffect(() => {
121 | if (micCamDisabled) {
122 | call.camera.disable();
123 | call.microphone.disable();
124 | } else {
125 | call.camera.enable();
126 | call.microphone.enable();
127 | }
128 | }, [micCamDisabled, call]);
129 |
130 | if (!micState.hasBrowserPermission || !camState.hasBrowserPermission) {
131 | return ;
132 | }
133 |
134 | return (
135 |
136 |
Setup
137 |
138 |
142 |
150 |
151 |
152 | );
153 | }
154 |
155 | function CallUI() {
156 | const { useCallCallingState } = useCallStateHooks();
157 |
158 | const callingState = useCallCallingState();
159 |
160 | if (callingState !== CallingState.JOINED) {
161 | return ;
162 | }
163 |
164 | return ;
165 | }
166 |
167 | function UpcomingMeetingScreen() {
168 | const call = useStreamCall();
169 |
170 | return (
171 |
172 |
173 | This meeting has not started yet. It will start at{" "}
174 |
175 | {call.state.startsAt?.toLocaleString()}
176 |
177 |
178 | {call.state.custom.description && (
179 |
180 | Description:{" "}
181 | {call.state.custom.description}
182 |
183 | )}
184 |
185 | Go home
186 |
187 |
188 | );
189 | }
190 |
191 | function MeetingEndedScreen() {
192 | return (
193 |
194 |
This meeting has ended
195 |
196 | Go home
197 |
198 |
199 |
Recordings
200 |
201 |
202 |
203 | );
204 | }
205 |
--------------------------------------------------------------------------------
/src/app/meeting/[id]/left/page.tsx:
--------------------------------------------------------------------------------
1 | import { buttonClassName } from "@/components/Button";
2 | import { cn } from "@/lib/utils";
3 | import Link from "next/link";
4 |
5 | interface PageProps {
6 | params: { id: string };
7 | }
8 |
9 | export default function Page({ params: { id } }: PageProps) {
10 | return (
11 |
12 |
You left this meeting.
13 |
17 | Rejoin
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/meeting/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { currentUser } from "@clerk/nextjs/server";
2 | import { Metadata } from "next";
3 | import MeetingLoginPage from "./MeetingLoginPage";
4 | import MeetingPage from "./MeetingPage";
5 |
6 | interface PageProps {
7 | params: { id: string };
8 | searchParams: { guest: string };
9 | }
10 |
11 | export function generateMetadata({ params: { id } }: PageProps): Metadata {
12 | return {
13 | title: `Meeting ${id}`,
14 | };
15 | }
16 |
17 | export default async function Page({
18 | params: { id },
19 | searchParams: { guest },
20 | }: PageProps) {
21 | const user = await currentUser();
22 |
23 | const guestMode = guest === "true";
24 |
25 | if (!user && !guestMode) {
26 | return ;
27 | }
28 |
29 | return ;
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/meetings/MyMeetingsPage.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useUser } from "@clerk/nextjs";
4 | import { Call, useStreamVideoClient } from "@stream-io/video-react-sdk";
5 | import { Loader2 } from "lucide-react";
6 | import Link from "next/link";
7 | import { useEffect, useState } from "react";
8 |
9 | export default function MyMeetingsPage() {
10 | const { user } = useUser();
11 |
12 | const client = useStreamVideoClient();
13 |
14 | const [calls, setCalls] = useState();
15 |
16 | useEffect(() => {
17 | async function loadCalls() {
18 | if (!client || !user?.id) {
19 | return;
20 | }
21 |
22 | const { calls } = await client.queryCalls({
23 | sort: [{ field: "starts_at", direction: -1 }],
24 | filter_conditions: {
25 | starts_at: { $exists: true },
26 | $or: [
27 | { created_by_user_id: user.id },
28 | { members: { $in: [user.id] } },
29 | ],
30 | },
31 | });
32 |
33 | setCalls(calls);
34 | }
35 |
36 | loadCalls();
37 | }, [client, user?.id]);
38 |
39 | return (
40 |
41 |
My Meetings
42 | {!calls &&
}
43 | {calls?.length === 0 &&
No meetings found
}
44 |
45 | {calls?.map((call) => )}
46 |
47 |
48 | );
49 | }
50 |
51 | interface MeetingItemProps {
52 | call: Call;
53 | }
54 |
55 | function MeetingItem({ call }: MeetingItemProps) {
56 | const meetingLink = `/meeting/${call.id}`;
57 |
58 | const isInFuture =
59 | call.state.startsAt && new Date(call.state.startsAt) > new Date();
60 |
61 | const hasEnded = !!call.state.endedAt;
62 |
63 | return (
64 |
65 |
66 | {call.state.startsAt?.toLocaleString()}
67 | {isInFuture && " (Upcoming)"}
68 | {hasEnded && " (Ended)"}
69 |
70 | {call.state.custom.description}
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/src/app/meetings/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 | import MyMeetingsPage from "./MyMeetingsPage";
3 |
4 | export const metadata: Metadata = {
5 | title: "My Meetings",
6 | };
7 |
8 | export default function Page() {
9 | return ;
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import CreateMeetingPage from "./CreateMeetingPage";
2 |
3 | export default function Home() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/AudioVolumeIndicator.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Icon,
3 | createSoundDetector,
4 | useCallStateHooks,
5 | } from "@stream-io/video-react-sdk";
6 | import { useEffect, useState } from "react";
7 |
8 | export default function AudioVolumeIndicator() {
9 | const { useMicrophoneState } = useCallStateHooks();
10 | const { isEnabled, mediaStream } = useMicrophoneState();
11 | const [audioLevel, setAudioLevel] = useState(0);
12 |
13 | useEffect(() => {
14 | if (!isEnabled || !mediaStream) return;
15 |
16 | const disposeSoundDetector = createSoundDetector(
17 | mediaStream,
18 | ({ audioLevel: al }) => setAudioLevel(al),
19 | { detectionFrequencyInMs: 80, destroyStreamOnStop: false },
20 | );
21 |
22 | return () => {
23 | disposeSoundDetector().catch(console.error);
24 | };
25 | }, [isEnabled, mediaStream]);
26 |
27 | if (!isEnabled) return null;
28 |
29 | return (
30 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | export default function Button({
4 | className,
5 | ...props
6 | }: React.ButtonHTMLAttributes) {
7 | return ;
8 | }
9 |
10 | export const buttonClassName =
11 | "flex items-center justify-center gap-2 rounded-full bg-blue-500 px-3 py-2 font-semibold text-white transition-colors hover:bg-blue-600 active:bg-blue-600 disabled:bg-gray-200";
12 |
--------------------------------------------------------------------------------
/src/components/EndCallButton.tsx:
--------------------------------------------------------------------------------
1 | import useStreamCall from "@/hooks/useStreamCall";
2 | import { useCallStateHooks } from "@stream-io/video-react-sdk";
3 |
4 | export default function EndCallButton() {
5 | const call = useStreamCall();
6 |
7 | const { useLocalParticipant } = useCallStateHooks();
8 | const localParticipant = useLocalParticipant();
9 |
10 | const participantIsChannelOwner =
11 | localParticipant &&
12 | call.state.createdBy &&
13 | localParticipant.userId === call.state.createdBy.id;
14 |
15 | if (!participantIsChannelOwner) {
16 | return null;
17 | }
18 |
19 | return (
20 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/FlexibleCallLayout.tsx:
--------------------------------------------------------------------------------
1 | import useStreamCall from "@/hooks/useStreamCall";
2 | import {
3 | CallControls,
4 | PaginatedGridLayout,
5 | SpeakerLayout,
6 | } from "@stream-io/video-react-sdk";
7 | import {
8 | BetweenHorizonalEnd,
9 | BetweenVerticalEnd,
10 | LayoutGrid,
11 | } from "lucide-react";
12 | import { useRouter } from "next/navigation";
13 | import { useState } from "react";
14 | import EndCallButton from "./EndCallButton";
15 |
16 | type CallLayout = "speaker-vert" | "speaker-horiz" | "grid";
17 |
18 | export default function FlexibleCallLayout() {
19 | const [layout, setLayout] = useState("speaker-vert");
20 |
21 | const call = useStreamCall();
22 |
23 | const router = useRouter();
24 |
25 | return (
26 |
27 |
28 |
29 | router.push(`/meeting/${call.id}/left`)} />
30 |
31 |
32 | );
33 | }
34 |
35 | interface CallLayoutButtonsProps {
36 | layout: CallLayout;
37 | setLayout: (layout: CallLayout) => void;
38 | }
39 |
40 | function CallLayoutButtons({ layout, setLayout }: CallLayoutButtonsProps) {
41 | return (
42 |
43 |
48 |
53 |
56 |
57 | );
58 | }
59 |
60 | interface CallLayoutViewProps {
61 | layout: CallLayout;
62 | }
63 |
64 | function CallLayoutView({ layout }: CallLayoutViewProps) {
65 | if (layout === "speaker-vert") {
66 | return ;
67 | }
68 |
69 | if (layout === "speaker-horiz") {
70 | return ;
71 | }
72 |
73 | if (layout === "grid") {
74 | return ;
75 | }
76 |
77 | return null;
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
2 | import Link from "next/link";
3 |
4 | export default function Navbar() {
5 | return (
6 |
7 |
8 |
New meeting
9 |
10 |
11 | Meetings
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/PermissionPrompt.tsx:
--------------------------------------------------------------------------------
1 | import { Mic, Webcam } from "lucide-react";
2 |
3 | export default function PermissionPrompt() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 | Please allow access to your microphone and camera to join the call
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/RecordingsList.tsx:
--------------------------------------------------------------------------------
1 | import useLoadRecordings from "@/hooks/useLoadRecordings";
2 | import useStreamCall from "@/hooks/useStreamCall";
3 | import { useUser } from "@clerk/nextjs";
4 | import { Loader2 } from "lucide-react";
5 | import Link from "next/link";
6 |
7 | export default function RecordingsList() {
8 | const call = useStreamCall();
9 |
10 | const { recordings, recordingsLoading } = useLoadRecordings(call);
11 |
12 | const { user, isLoaded: userLoaded } = useUser();
13 |
14 | if (userLoaded && !user) {
15 | return (
16 | You must be logged in to view recordings.
17 | );
18 | }
19 |
20 | if (recordingsLoading) return ;
21 |
22 | return (
23 |
24 | {recordings.length === 0 &&
No recordings for this meeting.
}
25 |
26 | {recordings
27 | .sort((a, b) => b.end_time.localeCompare(a.end_time))
28 | .map((recording) => (
29 | -
30 |
35 | {new Date(recording.end_time).toLocaleString()}
36 |
37 |
38 | ))}
39 |
40 |
41 | Note: It can take up to 1 minute before new recordings show up.
42 |
43 | You can refresh the page to see if new recordings are available.
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/hooks/useLoadCall.ts:
--------------------------------------------------------------------------------
1 | import { Call, useStreamVideoClient } from "@stream-io/video-react-sdk";
2 | import { useEffect, useState } from "react";
3 |
4 | export default function useLoadCall(id: string) {
5 | const client = useStreamVideoClient();
6 |
7 | const [call, setCall] = useState();
8 | const [callLoading, setCallLoading] = useState(true);
9 |
10 | useEffect(() => {
11 | async function loadCall() {
12 | setCallLoading(true);
13 |
14 | if (!client) return;
15 |
16 | const { calls } = await client.queryCalls({
17 | filter_conditions: { id },
18 | });
19 |
20 | if (calls.length > 0) {
21 | const call = calls[0];
22 |
23 | await call.get();
24 |
25 | setCall(call);
26 | }
27 |
28 | setCallLoading(false);
29 | }
30 | loadCall();
31 | }, [client, id]);
32 |
33 | return { call, callLoading };
34 | }
35 |
--------------------------------------------------------------------------------
/src/hooks/useLoadRecordings.ts:
--------------------------------------------------------------------------------
1 | import { useUser } from "@clerk/nextjs";
2 | import { Call, CallRecording } from "@stream-io/video-react-sdk";
3 | import { useEffect, useState } from "react";
4 |
5 | export default function useLoadRecordings(call: Call) {
6 | const { user } = useUser();
7 |
8 | const [recordings, setRecordings] = useState([]);
9 | const [recordingsLoading, setRecordingsLoading] = useState(true);
10 |
11 | useEffect(() => {
12 | async function loadRecordings() {
13 | setRecordingsLoading(true);
14 |
15 | if (!user?.id) return;
16 |
17 | const { recordings } = await call.queryRecordings();
18 | setRecordings(recordings);
19 |
20 | setRecordingsLoading(false);
21 | }
22 |
23 | loadRecordings();
24 | }, [call, user?.id]);
25 |
26 | return { recordings, recordingsLoading };
27 | }
28 |
--------------------------------------------------------------------------------
/src/hooks/useStreamCall.ts:
--------------------------------------------------------------------------------
1 | import { useCall } from "@stream-io/video-react-sdk";
2 |
3 | export default function useStreamCall() {
4 | const call = useCall();
5 |
6 | if (!call) {
7 | throw new Error(
8 | "useStreamCall must be used within a StreamCall component with a valid call prop.",
9 | );
10 | }
11 |
12 | return call;
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import clsx, { ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from "@clerk/nextjs";
2 |
3 | export default authMiddleware({
4 | publicRoutes: ["/meeting/:id*"],
5 | });
6 |
7 | export const config = {
8 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
9 | };
10 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13 | "gradient-conic":
14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------