diff --git a/packages/app/src/Components/Icons/icons.svg b/packages/app/src/Components/Icons/icons.svg index a21261b3..08f2fa96 100644 --- a/packages/app/src/Components/Icons/icons.svg +++ b/packages/app/src/Components/Icons/icons.svg @@ -463,6 +463,14 @@ - + + + + + + + + + \ No newline at end of file diff --git a/packages/app/src/Components/LiveStream/VU.tsx b/packages/app/src/Components/LiveStream/VU.tsx new file mode 100644 index 00000000..4550dcea --- /dev/null +++ b/packages/app/src/Components/LiveStream/VU.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef } from "react"; + +export default function VuBar({ + track, + full, + width, + height, + className, +}: { + track?: MediaStreamTrack; + full?: boolean; + height?: number; + width?: number; + className?: string; +}) { + const ref = useRef(null); + + useEffect(() => { + if (ref && track) { + const audioContext = new AudioContext(); + + const trackClone = track; + const mediaStreamSource = audioContext.createMediaStreamSource(new MediaStream([trackClone])); + const analyser = audioContext.createAnalyser(); + const minVU = -60; + const maxVU = 0; + const minFreq = 50; + const maxFreq = 7_000; + analyser.minDecibels = -100; + analyser.maxDecibels = 0; + analyser.smoothingTimeConstant = 0.4; + analyser.fftSize = 1024; + mediaStreamSource.connect(analyser); + + const dataArray = new Uint8Array(analyser.frequencyBinCount); + + const filteredAudio = (i: Uint8Array) => { + const binFreq = audioContext.sampleRate / 2 / dataArray.length; + return i.subarray(minFreq / binFreq, maxFreq / binFreq); + }; + const peakVolume = (data: Uint8Array) => { + const max = data.reduce((acc, v) => (v > acc ? v : acc), 0); + return (maxVU - minVU) * (max / 256) + minVU; + }; + + const canvas = ref.current!; + const ctx = canvas.getContext("2d")!; + const t = setInterval(() => { + analyser.getByteFrequencyData(dataArray); + const data = filteredAudio(dataArray); + const vol = peakVolume(data); + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + if (full) { + ctx.fillStyle = "#00FF00"; + for (let x = 0; x < data.length; x++) { + const bx = data[x]; + const h = canvas.height / data.length; + ctx.fillRect(0, x * h, (bx / 255) * canvas.width, h); + } + } + + const barLen = ((vol - minVU) / (maxVU - minVU)) * canvas.height; + ctx.fillStyle = "#fff"; + ctx.fillRect(0, canvas.height - barLen, canvas.width, barLen); + }, 50); + + return () => { + clearInterval(t); + audioContext.close(); + }; + } + }, [ref, track, full]); + + return ; +} diff --git a/packages/app/src/Components/LiveStream/livekit.tsx b/packages/app/src/Components/LiveStream/livekit.tsx index 3051c01a..2fcb6b00 100644 --- a/packages/app/src/Components/LiveStream/livekit.tsx +++ b/packages/app/src/Components/LiveStream/livekit.tsx @@ -1,25 +1,43 @@ -import { LiveKitRoom as LiveKitRoomContext, RoomAudioRenderer, useParticipants } from "@livekit/components-react"; +/* eslint-disable max-lines */ +import { + LiveKitRoom as LiveKitRoomContext, + RoomAudioRenderer, + useEnsureRoom, + useParticipantPermissions, + useParticipants, +} from "@livekit/components-react"; import { dedupe, unixNow } from "@snort/shared"; -import { EventKind, NostrLink, RequestBuilder, TaggedNostrEvent } from "@snort/system"; +import { EventKind, EventPublisher, NostrLink, RequestBuilder, SystemInterface, TaggedNostrEvent } from "@snort/system"; import { useRequestBuilder, useUserProfile } from "@snort/system-react"; -import { LocalParticipant, RemoteParticipant } from "livekit-client"; +import classNames from "classnames"; +import { LocalParticipant, LocalTrackPublication, RemoteParticipant, RoomEvent, Track } from "livekit-client"; import { useEffect, useMemo, useState } from "react"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; +import Text from "@/Components/Text/Text"; import useEventPublisher from "@/Hooks/useEventPublisher"; import { extractStreamInfo } from "@/Utils/stream"; import AsyncButton from "../Button/AsyncButton"; +import IconButton from "../Button/IconButton"; import { ProxyImg } from "../ProxyImg"; import Avatar from "../User/Avatar"; import { AvatarGroup } from "../User/AvatarGroup"; import DisplayName from "../User/DisplayName"; +import ProfileImage from "../User/ProfileImage"; +import VuBar from "./VU"; + +enum RoomTab { + Participants, + Chat, +} export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent; canJoin?: boolean }) { const { stream, service, id } = extractStreamInfo(ev); - const { publisher } = useEventPublisher(); + const { publisher, system } = useEventPublisher(); const [join, setJoin] = useState(false); const [token, setToken] = useState(); + const [tab, setTab] = useState(RoomTab.Participants); async function getToken() { if (!service || !publisher) return; @@ -43,6 +61,17 @@ export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent; can } } + async function publishPresence(publisher: EventPublisher, system: SystemInterface) { + const e = await publisher.generic(eb => { + const aTag = NostrLink.fromEvent(ev).toEventTag(); + return eb + .kind(10_312 as EventKind) + .tag(aTag!) + .tag(["expiration", (unixNow() + 60).toString()]); + }); + await system.BroadcastEvent(e); + } + useEffect(() => { if (join && !token) { getToken() @@ -51,6 +80,18 @@ export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent; can } }, [join]); + useEffect(() => { + if (token && publisher && system) { + publishPresence(publisher, system); + const t = setInterval(async () => { + if (token) { + publishPresence(publisher, system); + } + }, 60_000); + return () => clearInterval(t); + } + }, [token, publisher, system]); + if (!join) { return (
@@ -65,8 +106,8 @@ export default function LiveKitRoom({ ev, canJoin }: { ev: TaggedNostrEvent; can } return ( - - + + ); } @@ -76,9 +117,9 @@ function RoomHeader({ ev }: { ev: TaggedNostrEvent }) { return (
{image ? :
} -
+
{title}
-
+
@@ -86,19 +127,162 @@ function RoomHeader({ ev }: { ev: TaggedNostrEvent }) { ); } -function ParticipantList({ ev }: { ev: TaggedNostrEvent }) { - const participants = useParticipants(); +function RoomBody({ ev, tab, onSelectTab }: { ev: TaggedNostrEvent; tab: RoomTab; onSelectTab: (t: RoomTab) => void }) { + const participants = useParticipants({ + updateOnlyOn: [ + RoomEvent.ParticipantConnected, + RoomEvent.ParticipantDisconnected, + RoomEvent.ParticipantPermissionsChanged, + RoomEvent.TrackMuted, + RoomEvent.TrackPublished, + RoomEvent.TrackUnmuted, + RoomEvent.TrackUnmuted, + ], + }); return (
-

- -

-
- {participants.map(a => ( - - ))} + +
+
onSelectTab(RoomTab.Participants)}> + +
+
onSelectTab(RoomTab.Chat)}> + +
+ {tab === RoomTab.Participants && ( +
+ {participants.map(a => ( + + ))} +
+ )} + {tab === RoomTab.Chat && ( + <> + + + + )} +
+ ); +} + +function MyControls() { + const room = useEnsureRoom(); + const p = room.localParticipant; + const permissions = useParticipantPermissions({ + participant: p, + }); + useEffect(() => { + if (permissions && p instanceof LocalParticipant) { + const handler = (lt: LocalTrackPublication) => { + lt.mute(); + }; + p.on("localTrackPublished", handler); + if (permissions.canPublish && p.audioTrackPublications.size === 0) { + p.setMicrophoneEnabled(true); + } + return () => { + p.off("localTrackPublished", handler); + }; + } + }, [p, permissions]); + const isMuted = p.getTrackPublication(Track.Source.Microphone)?.isMuted ?? true; + + return ( +
+ {p.permissions?.canPublish && ( + { + if (isMuted) { + await p.setMicrophoneEnabled(true); + } else { + await p.setMicrophoneEnabled(false); + } + }} + /> + )} + {/**/} +
+ ); +} + +function RoomChat({ ev }: { ev: TaggedNostrEvent }) { + const link = NostrLink.fromEvent(ev); + const sub = useMemo(() => { + const sub = new RequestBuilder(`room-chat:${link.tagKey}`); + sub.withOptions({ leaveOpen: true, replaceable: true }); + sub.withFilter().replyToLink([link]).kinds([EventKind.LiveEventChat]).limit(100); + return sub; + }, [link.tagKey]); + const chat = useRequestBuilder(sub); + + return ( +
+
+ {chat + .sort((a, b) => b.created_at - a.created_at) + .map(e => ( + + ))} +
+
+ ); +} + +function ChatMessage({ ev }: { ev: TaggedNostrEvent }) { + return ( +
+ + +
+ ); +} + +function WriteChatMessage({ ev }: { ev: TaggedNostrEvent }) { + const link = NostrLink.fromEvent(ev); + const [chat, setChat] = useState(""); + const { publisher, system } = useEventPublisher(); + const { formatMessage } = useIntl(); + + async function sendMessage() { + if (!publisher || !system || chat.length < 2) return; + const eChat = await publisher.generic(eb => eb.kind(EventKind.LiveEventChat).tag(link.toEventTag()!).content(chat)); + await system.BroadcastEvent(eChat); + setChat(""); + } + + return ( +
+ setChat(e.target.value)} + className="grow" + onKeyDown={e => { + if (e.key === "Enter") { + sendMessage(); + } + }} + /> +
); } @@ -116,16 +300,27 @@ function NostrParticipants({ ev }: { ev: TaggedNostrEvent }) { }, [link.tagKey]); const presense = useRequestBuilder(sub); - return a.pubkey))} size={32} />; + const filteredPresence = presense.filter(ev => ev.created_at > unixNow() - 600); + return a.pubkey))} size={32} />; } function LiveKitUser({ p }: { p: RemoteParticipant | LocalParticipant }) { const pubkey = p.identity.startsWith("guest-") ? "anon" : p.identity; const profile = useUserProfile(pubkey); + const mic = p.getTrackPublication(Track.Source.Microphone); + return (
- - +
+ {mic?.audioTrack?.mediaStreamTrack && ( + + )} + +
+
+ + {p.permissions?.canPublish &&
Speaker
} +
); } diff --git a/packages/app/src/Components/User/ProfileImage.tsx b/packages/app/src/Components/User/ProfileImage.tsx index c46c054f..4dd6b9df 100644 --- a/packages/app/src/Components/User/ProfileImage.tsx +++ b/packages/app/src/Components/User/ProfileImage.tsx @@ -118,7 +118,7 @@ export default function ProfileImage({ const classNamesOverInner = classNames( "min-w-0", { - "grid grid-cols-[min-content_auto] gap-3 items-center": showUsername, + "grid grid-cols-[min-content_auto] gap-2 items-center": showUsername, }, className, ); diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index 9ba6eb37..3aa06004 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -1431,6 +1431,9 @@ "W9355R": { "defaultMessage": "Unmute" }, + "WTrOy3": { + "defaultMessage": "Chat" + }, "WeLEuL": { "defaultMessage": "From Server" }, @@ -1485,6 +1488,9 @@ "YH2RKk": { "defaultMessage": "Popular media servers." }, + "YLGfQn": { + "defaultMessage": "Write message" + }, "YQZY/S": { "defaultMessage": "It looks like you dont follow enough people, take a look at {newUsersPage} to discover people to follow!" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index 34ca47ed..d0d9a91d 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -474,6 +474,7 @@ "W2PiAr": "{n} Blocked", "W4SaxY": "Local", "W9355R": "Unmute", + "WTrOy3": "Chat", "WeLEuL": "From Server", "Wj5TbN": "Issues", "WmZhfL": "Automatically translate notes to your local language", @@ -492,6 +493,7 @@ "YDMrKK": "Users", "YDURw6": "Service URL", "YH2RKk": "Popular media servers.", + "YLGfQn": "Write message", "YQZY/S": "It looks like you dont follow enough people, take a look at {newUsersPage} to discover people to follow!", "YR2I9M": "No keys, no {app}, There is no way to reset it if you don't back up. It only takes a minute.", "YU7ZYp": "Public Chat", diff --git a/packages/system/src/event-kind.ts b/packages/system/src/event-kind.ts index 842acaa2..25a4a46a 100644 --- a/packages/system/src/event-kind.ts +++ b/packages/system/src/event-kind.ts @@ -54,7 +54,8 @@ const enum EventKind { LongFormTextNote = 30023, // NIP-23 AppData = 30_078, // NIP-78 - LiveEvent = 30311, // NIP-102 + LiveEvent = 30311, // NIP-53 + LiveEventChat = 1311, // NIP-53 UserStatus = 30315, // NIP-38 ZapstrTrack = 31337, ApplicationHandler = 31_990,