-
Notifications
You must be signed in to change notification settings - Fork 122
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
424 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} |
Oops, something went wrong.