Skip to content

Commit

Permalink
feat: basic livekit setup
Browse files Browse the repository at this point in the history
  • Loading branch information
v0l committed Sep 19, 2024
1 parent 1121d8d commit 6bc882f
Show file tree
Hide file tree
Showing 6 changed files with 424 additions and 16 deletions.
3 changes: 3 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"dependencies": {
"@cashu/cashu-ts": "^1.0.0-rc.3",
"@here/maps-api-for-javascript": "^1.50.0",
"@livekit/components-react": "^2.5.4",
"@livekit/protocol": "^1.22.0",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"@scure/base": "^1.1.6",
Expand All @@ -29,6 +31,7 @@
"highlight.js": "^11.8.0",
"latlon-geohash": "^2.0.0",
"light-bolt11-decoder": "^2.1.0",
"livekit-client": "^2.5.2",
"lottie-react": "^2.4.0",
"marked": "^9.1.0",
"marked-footnote": "^1.0.0",
Expand Down
37 changes: 26 additions & 11 deletions packages/app/src/Components/LiveStream/LiveEvent.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
import { NostrEvent, NostrLink } from "@snort/system";
import { useState } from "react";
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { lazy, Suspense, useState } from "react";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";

import Icon from "@/Components/Icons/Icon";
import { findTag } from "@/Utils";
import { extractStreamInfo } from "@/Utils/stream";

import NoteAppHandler from "../Event/Note/NoteAppHandler";
import ProfileImage from "../User/ProfileImage";
const LiveKitRoom = lazy(() => import("./livekit"));

export function LiveEvent({ ev }: { ev: NostrEvent }) {
const title = findTag(ev, "title");
const status = findTag(ev, "status");
const starts = Number(findTag(ev, "starts"));
const host = ev.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev.pubkey;

export function LiveEvent({ ev }: { ev: TaggedNostrEvent }) {
const service = ev.tags.find(a => a[0] === "streaming")?.at(1);
function inner() {
if (service?.endsWith(".m3u8")) {
return <LiveStreamEvent ev={ev} />
} else if (service?.startsWith("wss+livekit://")) {
return <Suspense>
<LiveKitRoom ev={ev} canJoin={true} />
</Suspense>
}
return <NoteAppHandler ev={ev} />
}

return inner();
}

function LiveStreamEvent({ ev }: { ev: TaggedNostrEvent }) {
const { title, status, starts, host } = extractStreamInfo(ev);
const [play, setPlay] = useState(false);

function statusLine() {
Expand All @@ -38,7 +55,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
return (
<b className="uppercase">
{new Intl.DateTimeFormat(undefined, { dateStyle: "full", timeStyle: "short" }).format(
new Date(starts * 1000),
new Date(Number(starts) * 1000),
)}
</b>
);
Expand Down Expand Up @@ -73,8 +90,6 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
const link = `https://zap.stream/embed/${NostrLink.fromEvent(ev).encode()}`;
return (
<iframe
// eslint-disable-next-line react/no-unknown-property
credentialless=""
src={link}
width="100%"
style={{
Expand All @@ -86,7 +101,7 @@ export function LiveEvent({ ev }: { ev: NostrEvent }) {
return (
<div className="sm:flex g12 br p24 bg-primary items-center">
<div>
<ProfileImage pubkey={host} showUsername={false} size={56} />
<ProfileImage pubkey={host!} showUsername={false} size={56} />
</div>
<div className="flex flex-col g8 grow">
<div className="font-semibold text-3xl">{title}</div>
Expand Down
117 changes: 117 additions & 0 deletions packages/app/src/Components/LiveStream/livekit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { LiveKitRoom as LiveKitRoomContext, RoomAudioRenderer, useParticipants } from "@livekit/components-react";
import { dedupe, unixNow } from "@snort/shared";
import { EventKind, NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system";
import { useRequestBuilder, useUserProfile } from "@snort/system-react";
import { LocalParticipant, RemoteParticipant } from "livekit-client";
import { useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";

import useEventPublisher from "@/Hooks/useEventPublisher";
import { extractStreamInfo } from "@/Utils/stream";

import AsyncButton from "../Button/AsyncButton";
import { ProxyImg } from "../ProxyImg";
import Avatar from "../User/Avatar";
import { AvatarGroup } from "../User/AvatarGroup";
import DisplayName from "../User/DisplayName";

export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent, canJoin?: boolean }) {
const { stream, service, id } = extractStreamInfo(ev);
const { publisher } = useEventPublisher();
const [join, setJoin] = useState(false);
const [token, setToken] = useState<string>();

async function getToken() {
if (!service || !publisher)
return;
const url = `${service}/api/v1/nests/${id}`;
const auth = await publisher.generic(eb => {
eb.kind(EventKind.HttpAuthentication);
eb.tag(["url", url]);
eb.tag(["u", url])
eb.tag(["method", "GET"]);
return eb;
});
const rsp = await fetch(url, {
headers: {
authorization: `Nostr ${window.btoa(JSON.stringify(auth))}`,
}
});

const text = await rsp.text();
if (rsp.ok) {
return JSON.parse(text) as { token: string };
}
}

useEffect(() => {
if (join && !token) {
getToken().then(t => setToken(t?.token)).catch(console.error);
}
}, [join]);

if (!join) {
return <div className="p flex flex-col gap-2">
<RoomHeader ev={ev} />
{(canJoin ?? false) && <AsyncButton onClick={() => setJoin(true)}>
<FormattedMessage defaultMessage="Join Room" />
</AsyncButton>}
</div>
}
return <LiveKitRoomContext token={token} serverUrl={stream?.replace("wss+livekit://", "wss://")} connect={true}>
<RoomAudioRenderer volume={1} />
<ParticipantList ev={ev} />
</LiveKitRoomContext>
}

function RoomHeader({ ev }: { ev: TaggedNostrEvent }) {
const { image, title } = extractStreamInfo(ev);
return <div className="relative rounded-xl h-[140px] w-full overflow-hidden">
{image ? <ProxyImg src={image} className="w-full" /> :
<div className="absolute bg-gray-dark w-full h-full" />}
<div className="absolute left-4 top-4 w-full flex justify-between pr-4">
<div className="text-2xl">
{title}
</div>
<div>
<NostrParticipants ev={ev} />
</div>
</div>

</div>
}

function ParticipantList({ ev }: { ev: TaggedNostrEvent }) {
const participants = useParticipants()
return <div className="p">
<RoomHeader ev={ev} />
<h3>
<FormattedMessage defaultMessage="Participants" />
</h3>
<div className="grid grid-cols-4">
{participants.map(a => <LiveKitUser p={a} key={a.identity} />)}
</div>

</div>
}

function NostrParticipants({ ev }: { ev: TaggedNostrEvent }) {
const link = NostrLink.fromEvent(ev);
const sub = useMemo(() => {
const sub = new RequestBuilder(`livekit-participants:${link.tagKey}`);
sub.withFilter().replyToLink([link]).kinds([10_312 as EventKind]).since(unixNow() - 600);
return sub;
}, [link.tagKey]);

const presense = useRequestBuilder(sub);
return <AvatarGroup ids={dedupe(presense.map(a => a.pubkey))} size={32} />
}

function LiveKitUser({ p }: { p: RemoteParticipant | LocalParticipant }) {
const pubkey = p.identity.startsWith("guest-") ? "anon" : p.identity
const profile = useUserProfile(pubkey);
return <div className="flex flex-col gap-2 items-center text-center">
<Avatar pubkey={pubkey} className={p.isSpeaking ? "outline" : ""} user={profile} size={48} />
<DisplayName pubkey={pubkey} user={pubkey === "anon" ? { name: "Anon" } : profile} />
</div>
}
4 changes: 2 additions & 2 deletions packages/app/src/Components/User/AvatarGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import React from "react";

import ProfileImage from "@/Components/User/ProfileImage";

export function AvatarGroup({ ids, onClick }: { ids: HexKey[]; onClick?: () => void }) {
export function AvatarGroup({ ids, onClick, size }: { ids: HexKey[]; onClick?: () => void, size?: number }) {
return ids.map((a, index) => (
<div className={`inline-block ${index > 0 ? "-ml-4" : ""}`} key={a} style={{ zIndex: ids.length - index }}>
<ProfileImage link="" onClick={onClick} showFollowDistance={false} pubkey={a} size={24} showUsername={false} />
<ProfileImage link="" onClick={onClick} showFollowDistance={false} pubkey={a} size={size ?? 24} showUsername={false} />
</div>
));
}
72 changes: 72 additions & 0 deletions packages/app/src/Utils/stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { TaggedNostrEvent } from "@snort/system";

export function getHost(ev?: TaggedNostrEvent) {
return ev?.tags.find(a => a[0] === "p" && a[3] === "host")?.[1] ?? ev?.pubkey ?? "";
}

export type StreamState = "live" | "ended" | "planned";

export interface StreamInfo {
id?: string;
title?: string;
summary?: string;
image?: string;
thumbnail?: string;
status?: StreamState;
stream?: string;
recording?: string;
contentWarning?: string;
tags: Array<string>;
goal?: string;
participants?: string;
starts?: string;
ends?: string;
service?: string;
host?: string;
gameId?: string;
}

const gameTagFormat = /^[a-z-]+:[a-z0-9-]+$/i;
export function extractStreamInfo(ev?: TaggedNostrEvent) {
const ret = {
host: getHost(ev),
} as StreamInfo;
const matchTag = (tag: Array<string>, k: string, into: (v: string) => void) => {
if (tag[0] === k) {
into(tag[1]);
}
};

for (const t of ev?.tags ?? []) {
matchTag(t, "d", v => (ret.id = v));
matchTag(t, "title", v => (ret.title = v));
matchTag(t, "summary", v => (ret.summary = v));
matchTag(t, "image", v => (ret.image = v));
matchTag(t, "thumbnail", v => (ret.thumbnail = v));
matchTag(t, "status", v => (ret.status = v as StreamState));
if (t[0] === "streaming") {
matchTag(t, "streaming", v => (ret.stream = v));
}
matchTag(t, "recording", v => (ret.recording = v));
matchTag(t, "url", v => (ret.recording = v));
matchTag(t, "content-warning", v => (ret.contentWarning = v));
matchTag(t, "current_participants", v => (ret.participants = v));
matchTag(t, "goal", v => (ret.goal = v));
matchTag(t, "starts", v => (ret.starts = v));
matchTag(t, "ends", v => (ret.ends = v));
matchTag(t, "service", v => (ret.service = v));
}
const { regularTags } = sortStreamTags(ev?.tags ?? []);
ret.tags = regularTags;

return ret;
}


export function sortStreamTags(tags: Array<string | Array<string>>) {
const plainTags = tags.filter(a => (Array.isArray(a) ? a[0] === "t" : true)).map(a => (Array.isArray(a) ? a[1] : a));

const regularTags = plainTags.filter(a => !a.match(gameTagFormat)) ?? [];
const prefixedTags = plainTags.filter(a => !regularTags.includes(a));
return { regularTags, prefixedTags };
}
Loading

0 comments on commit 6bc882f

Please sign in to comment.