Skip to content

Commit

Permalink
feat: add host page and update WHIP settings (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
cacheonly authored Oct 30, 2023
1 parent 28adbfc commit 95e054f
Show file tree
Hide file tree
Showing 12 changed files with 452 additions and 90 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<img width="1498" alt="Screenshot 2023-02-14 at 8 13 19 AM" src="https://user-images.githubusercontent.com/304392/218794329-94641d24-461b-4c3d-b33e-0d2b3ef8fcc1.png" />

This is a demo app for livestreaming via RTMP or WHIP using LiveKit. One user is a broadcaster who gets an RTMP/WHIP for streaming (eg, via OBS). Other users can view their stream and chat.
This is a demo app for livestreaming via RTMP or WHIP using LiveKit. One user is a broadcaster who gets an RTMP/WHIP for streaming (eg, via OBS). Other users can view their stream and chat. We also let you broadcast directly from your device from the "Host" page.

Today most livestreams experience a 5–30 second lag, which is evident in the delay it takes for streamers to respond to chats. Those streams use HLS, which leverages existing CDNs by uploading 5–30 second video chunks, which clients download one chunk at a time. HLS is hugely scalable, but it comes with latency.

Expand Down
203 changes: 144 additions & 59 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"start": "next start"
},
"dependencies": {
"@livekit/components-react": "^1.0.6",
"@livekit/components-react": "^1.3.0",
"@next/font": "^13.1.6",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.2",
Expand Down
8 changes: 7 additions & 1 deletion src/components/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ export function NavBar() {
Setup
</Link>
<Link
href="/channel/donald-huh"
href="/channel/example/host"
className="flex items-center text-lg font-semibold text-zinc-600 hover:text-zinc-900 dark:text-zinc-100 sm:text-sm"
>
Host
</Link>
<Link
href="/channel/example"
className="flex items-center text-lg font-semibold text-zinc-600 hover:text-zinc-900 dark:text-zinc-100 sm:text-sm"
>
Watch
Expand Down
8 changes: 6 additions & 2 deletions src/components/channel/ChannelInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,21 @@ export default function ChannelInfo({ streamerIdentity }: Props) {
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="grid place-items-center">
{participant && (
<div className="absolute z-10 h-11 w-11 animate-ping rounded-full bg-red-600 dark:bg-red-400" />
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className={cn(
"h-16 w-16 rounded-full border-2 border-white bg-gray-500 dark:border-zinc-900",
"z-20 h-16 w-16 rounded-full border-2 border-white bg-gray-500 dark:border-zinc-900",
participant && "ring-2 ring-red-600"
)}
src={`https://api.dicebear.com/5.x/open-peeps/svg?seed=${streamerIdentity}&size=64&face=smile,cute`}
alt={streamerIdentity}
/>

{participant && (
<div className="absolute mt-14 w-12 rounded-xl border-2 border-white bg-red-600 p-1 text-center text-xs font-bold uppercase text-white transition-all dark:border-zinc-900">
<div className="absolute z-30 mt-14 w-12 rounded-xl border-2 border-white bg-red-600 p-1 text-center text-xs font-bold uppercase text-white transition-all dark:border-zinc-900">
Live
</div>
)}
Expand Down
133 changes: 133 additions & 0 deletions src/components/channel/HostStudio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Button } from "@/components/ui";
import { useLocalParticipant } from "@livekit/components-react";
import { createLocalTracks, Track, type LocalTrack } from "livekit-client";
import { useCallback, useEffect, useRef, useState } from "react";

interface Props {
slug: string;
}

export default function HostStudio({ slug }: Props) {
const [videoTrack, setVideoTrack] = useState<LocalTrack>();
const [audioTrack, setAudioTrack] = useState<LocalTrack>();
const [isPublishing, setIsPublishing] = useState(false);
const [isUnpublishing, setIsUnpublishing] = useState(false);
const previewVideoEl = useRef<HTMLVideoElement>(null);

const { localParticipant } = useLocalParticipant();

const createTracks = async () => {
const tracks = await createLocalTracks({ audio: true, video: true });
tracks.forEach((track) => {
switch (track.kind) {
case Track.Kind.Video: {
if (previewVideoEl?.current) {
track.attach(previewVideoEl.current);
}
setVideoTrack(track);
break;
}
case Track.Kind.Audio: {
setAudioTrack(track);
break;
}
}
});
};

useEffect(() => {
void createTracks();
}, []);

useEffect(() => {
return () => {
videoTrack?.stop();
audioTrack?.stop();
};
}, [videoTrack, audioTrack]);

const togglePublishing = useCallback(async () => {
if (isPublishing && localParticipant) {
setIsUnpublishing(true);

if (videoTrack) {
void localParticipant.unpublishTrack(videoTrack);
}
if (audioTrack) {
void localParticipant.unpublishTrack(audioTrack);
}

await createTracks();

setTimeout(() => {
setIsUnpublishing(false);
}, 2000);
} else if (localParticipant) {
if (videoTrack) {
void localParticipant.publishTrack(videoTrack);
}
if (audioTrack) {
void localParticipant.publishTrack(audioTrack);
}
}

setIsPublishing((prev) => !prev);
}, [audioTrack, isPublishing, localParticipant, videoTrack]);

return (
<div className="flex w-full flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex gap-[5px] text-lg font-bold">
{isPublishing && !isUnpublishing ? (
<div className="flex items-center gap-1">
<span className="relative mr-1 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500"></span>
</span>
LIVE
</div>
) : (
"Ready to stream"
)}{" "}
as{" "}
<div className="italic text-purple-500 dark:text-purple-300">
{slug}
</div>
</div>
<div className="flex gap-2">
{isPublishing ? (
<Button
size="sm"
className=" bg-red-500 text-white hover:bg-red-700 dark:bg-red-500 dark:text-white dark:hover:bg-red-600"
onClick={() => void togglePublishing()}
disabled={isUnpublishing}
>
{isUnpublishing ? "Stopping..." : "Stop stream"}
</Button>
) : (
<Button
size="sm"
className=" bg-blue-500 text-white hover:bg-blue-700 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-600"
onClick={() => void togglePublishing()}
>
Start stream
</Button>
)}
</div>
</div>
<div className="aspect-video rounded-sm border border-neutral-500 bg-neutral-800">
<video ref={previewVideoEl} width="100%" height="100%" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-purple-200 px-2 py-1 text-xs font-medium uppercase text-purple-600 ring-1 ring-inset ring-purple-600">
Note
</span>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Do not stream to RTMP/WHIP endpoint at the same time.
</p>
</div>
</div>
</div>
);
}
16 changes: 8 additions & 8 deletions src/components/channel/StreamPlayer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {
StartAudio,
useConnectionState,
useMediaTrack,
useRemoteParticipant,
useTracks,
} from "@livekit/components-react";
import { Track, type Participant } from "livekit-client";
import React, { useCallback, useRef, useState } from "react";
Expand Down Expand Up @@ -59,13 +59,13 @@ export const StreamPlayer = ({ participant }: { participant: Participant }) => {
const videoEl = useRef<HTMLVideoElement>(null);
const playerEl = useRef<HTMLDivElement>(null);

useMediaTrack(Track.Source.Camera, participant, {
element: videoEl,
});

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

const onVolumeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
Expand Down
96 changes: 96 additions & 0 deletions src/pages/channel/[slug]/host.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Chat from "@/components/channel/Chat";
import HostStudio from "@/components/channel/HostStudio";
import { env } from "@/env.mjs";
import { api } from "@/lib/api";
import { LiveKitRoom as RoomProvider } from "@livekit/components-react";
import jwt, { type JwtPayload } from "jwt-decode";
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { useEffect, useState } from "react";

interface Props {
slug: string;
}

export const getServerSideProps: GetServerSideProps<Props> = async ({
params,
}) => {
return Promise.resolve({
props: {
slug: params?.slug as string,
},
});
};

export default function ChannelHostPage({
slug,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const SESSION_STREAMER_TOKEN_KEY = `${slug}-streamer-token`;

const [streamerToken, setStreamerToken] = useState("");
const [queryEnabled, setQueryEnabled] = useState(false);

api.token.getWrite.useQuery(
{
roomName: slug,
identity: slug,
},
{
onSuccess: (data) => {
setStreamerToken(data?.token);
sessionStorage.setItem(SESSION_STREAMER_TOKEN_KEY, data?.token);
},
enabled: queryEnabled,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
}
);

// NOTE: This is a hack to persist the streamer token in the session storage
// so that the client doesn't have to create a streamer token every time they
// navigate back to the page.
useEffect(() => {
const sessionToken = sessionStorage.getItem(SESSION_STREAMER_TOKEN_KEY);

if (sessionToken) {
const payload: JwtPayload = jwt(sessionToken);

if (payload.exp) {
const expiry = new Date(payload.exp * 1000);
if (expiry < new Date()) {
sessionStorage.removeItem(SESSION_STREAMER_TOKEN_KEY);
setQueryEnabled(true);
return;
}
}

setStreamerToken(sessionToken);
} else {
setQueryEnabled(true);
}
}, [SESSION_STREAMER_TOKEN_KEY]);

if (streamerToken === "") {
return null;
}

return (
<RoomProvider
token={streamerToken}
serverUrl={env.NEXT_PUBLIC_LIVEKIT_WS_URL}
className="flex flex-1 flex-col"
>
<div className="flex h-full flex-1">
<div className="flex-1 flex-col p-12 dark:border-t-zinc-200 dark:bg-black">
<HostStudio slug={slug} />
</div>
<div className="sticky hidden w-80 border-l dark:border-zinc-800 dark:bg-zinc-900 md:block">
<div className="absolute top-0 bottom-0 right-0 flex h-full w-full flex-col gap-2 p-2">
<Chat viewerName={slug} />
</div>
</div>
</div>
</RoomProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function ChannelPage({
const [viewerToken, setViewerToken] = useState("");
const [queryEnabled, setQueryEnabled] = useState(false);

api.token.get.useQuery(
api.token.getRead.useQuery(
{
roomName: slug,
identity: generatedName,
Expand Down
2 changes: 1 addition & 1 deletion src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Link from "next/link";

export default function IndexPage() {
return (
<section className="items-startpx-4 container mx-auto flex max-w-[680px] flex-1 flex-col pt-6 pb-8 md:py-10">
<section className="items-start px-4 container mx-auto flex max-w-[680px] flex-1 flex-col pt-6 pb-8 md:py-10">
<div className="mx-auto flex w-full flex-col items-start gap-6">
<h1 className="text-3xl font-extrabold leading-tight tracking-tighter sm:text-3xl md:text-5xl lg:text-6xl">
Hello, broadcaster!
Expand Down
37 changes: 23 additions & 14 deletions src/server/api/routers/ingress.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CreateIngressOptions } from "livekit-server-sdk";
import {
IngressAudioEncodingPreset,
IngressInput,
Expand All @@ -22,22 +23,30 @@ export const ingressRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const { ingressClient } = ctx;

const options: CreateIngressOptions = {
name: input.roomSlug,
roomName: input.roomSlug,
participantName: input.streamerName,
participantIdentity: input.roomSlug,
};

if (input.isWhip) {
// https://docs.livekit.io/egress-ingress/ingress/overview/#bypass-transcoding-for-whip-sessions
options.bypassTranscoding = true;
} else {
options.video = {
source: TrackSource.CAMERA,
preset: IngressVideoEncodingPreset.H264_1080P_30FPS_3_LAYERS,
};
options.audio = {
source: TrackSource.MICROPHONE,
preset: IngressAudioEncodingPreset.OPUS_STEREO_96KBPS,
};
}

const ingress = await ingressClient.createIngress(
input.isWhip ? IngressInput.WHIP_INPUT : IngressInput.RTMP_INPUT,
{
name: input.roomSlug,
roomName: input.roomSlug,
participantName: input.streamerName,
participantIdentity: input.roomSlug,
video: {
source: TrackSource.CAMERA,
preset: IngressVideoEncodingPreset.H264_1080P_30FPS_3_LAYERS,
},
audio: {
source: TrackSource.MICROPHONE,
preset: IngressAudioEncodingPreset.OPUS_STEREO_96KBPS,
},
}
options
);

return ingress;
Expand Down
Loading

1 comment on commit 95e054f

@vercel
Copy link

@vercel vercel bot commented on 95e054f Oct 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.