diff --git a/README.md b/README.md index 83790258..f6ba9a88 100644 --- a/README.md +++ b/README.md @@ -319,8 +319,10 @@ These values are parsed by the Go backend and applied to WHIP/WHEP `PeerConnecti | `CHAT_DEFAULT_TTL` | How long idle chat sessions stay alive before they expire. | | `CHAT_CLEANUP_INTERVAL` | How often expired chat sessions are cleaned up. | -Broadcast Box also attaches a WebRTC data channel (`bb-chat-v1`) to WHIP/WHEP peer connections for simple -per-stream chat state, although the bundled frontend does not currently expose a chat UI. +Broadcast Box attaches a WebRTC data channel (`bb-chat-v1`) to WHIP/WHEP peer connections for simple per-stream +chat state. The bundled frontend does not currently expose a chat UI, but you can connect from your own client. + +See [CONNECTING.md](internal/chat/CONNECTING.md) for the message contract and a minimal standalone client example. ## CLI Flags diff --git a/internal/chat/CONNECTING.md b/internal/chat/CONNECTING.md new file mode 100644 index 00000000..e77086ed --- /dev/null +++ b/internal/chat/CONNECTING.md @@ -0,0 +1,116 @@ +# Chat Connection Quick Reference + +Use the WebRTC data channel label `bb-chat-v1` to connect to per-stream chat. + +## What the server expects + +- Data channel label: `bb-chat-v1` +- Outbound client message type: `chat.send` +- `text` length: 1-2000 chars +- `displayName` length: 1-80 chars + +Client -> server payload: + +```json +{ + "type": "chat.send", + "clientMsgId": "uuid-or-any-unique-id", + "text": "hello", + "displayName": "alice" +} +``` + +Server -> client message types: + +- `chat.connected` +- `chat.history` (contains `{ type: "message", message: ... }[]`) +- `chat.message` (single live message) +- `chat.ack` (echoes `clientMsgId`) +- `chat.error` (may include `clientMsgId`) + +Message shape: + +```json +{ + "id": "message-id", + "ts": 1730000000, + "text": "hello", + "displayName": "alice" +} +``` + +## Simple client example + +```ts +const CHAT_LABEL = "bb-chat-v1"; + +type Outbound = + | { type: "chat.connected" } + | { type: "chat.history"; events: Array<{ type: "message"; message: ChatMessage }> } + | { type: "chat.message"; eventId: number; message: ChatMessage } + | { type: "chat.ack"; clientMsgId: string } + | { type: "chat.error"; error: string; clientMsgId?: string }; + +type ChatMessage = { + id: string; + ts: number; + text: string; + displayName: string; +}; + +const chatChannel = peerConnection.createDataChannel(CHAT_LABEL); +const pending = new Map void>(); + +chatChannel.addEventListener("message", (event) => { + const payload = JSON.parse(event.data) as Outbound; + + if (payload.type === "chat.history") { + payload.events.forEach((e) => console.log("history", e.message)); + } + + if (payload.type === "chat.message") { + console.log("live", payload.message); + } + + if (payload.type === "chat.ack") { + pending.get(payload.clientMsgId)?.(); + pending.delete(payload.clientMsgId); + } + + if (payload.type === "chat.error" && payload.clientMsgId) { + pending.get(payload.clientMsgId)?.(new Error(payload.error)); + pending.delete(payload.clientMsgId); + } +}); + +function sendChat(text: string, displayName: string) { + if (chatChannel.readyState !== "open") { + throw new Error("chat channel is not open"); + } + + const clientMsgId = crypto.randomUUID(); + const payload = { + type: "chat.send", + clientMsgId, + text, + displayName, + }; + + return new Promise((resolve, reject) => { + pending.set(clientMsgId, (error?: Error) => { + if (error) reject(error); + else resolve(); + }); + + chatChannel.send(JSON.stringify(payload)); + }); +} +``` + +## Connection flow + +1. Create `RTCPeerConnection`. +2. Create chat data channel with label `bb-chat-v1` before SDP offer. +3. Handle inbound `chat.connected`, `chat.history`, `chat.message`, `chat.ack`, and `chat.error` payloads. +4. Send messages as `{ "type": "chat.send", "clientMsgId", "text", "displayName" }`. +5. Treat `chat.ack` as send success and `chat.error` as send failure. diff --git a/web/src/components/player/Player.tsx b/web/src/components/player/Player.tsx index 2ee0bb56..ad7e18b9 100644 --- a/web/src/components/player/Player.tsx +++ b/web/src/components/player/Player.tsx @@ -1,19 +1,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { MouseEvent } from 'react'; import PlayPauseComponent from "./components/PlayPauseComponent"; import VideoLayerSelectorComponent from "./components/VideoLayerSelectorComponent"; import AudioLayerSelectorComponent from "./components/AudioLayerSelectorComponent"; import CurrentViewersComponent from "./components/CurrentViewersComponent"; import { StreamStatus } from '../../providers/StatusProvider'; import { CurrentLayersMessage, PeerConnectionSetup, SetupPeerConnectionProps } from './functions/peerconnection'; -import { ArrowsPointingOutIcon, Square2StackIcon } from '@heroicons/react/20/solid'; +import { ChatAdapter } from '../../hooks/useChatSession'; +import { ArrowsPointingOutIcon, Square2StackIcon, XMarkIcon } from '@heroicons/react/20/solid'; +import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline'; import VolumeComponent from './components/VolumeComponent'; import { StatusMessageComponent } from './components/StatusMessageComponent'; -import { StreamMOTD } from './components/StreamMOTD'; interface PlayerProps { streamKey: string; cinemaMode: boolean; - onCloseStream?: () => void; + isChatOpen?: boolean; + onToggleChat?(): void; + onChatAdapterChange?(streamKey: string, adapter: ChatAdapter | undefined): void; + onStreamStatusChange?(streamKey: string, status: StreamStatus): void; + onCloseStream?(): void; } interface FullscreenElement extends HTMLElement { @@ -23,8 +29,15 @@ interface FullscreenElement extends HTMLElement { } const Player = (props: PlayerProps) => { - const { cinemaMode } = props; - const streamKey = decodeURIComponent(props.streamKey).replace(' ', '_') + const { + cinemaMode, + isChatOpen, + onToggleChat, + onChatAdapterChange, + onStreamStatusChange, + onCloseStream, + } = props + const streamKey = decodeURIComponent(props.streamKey).replace(/ /g, '_') const [currentStreamStatus, setCurrentStreamStatus] = useState({ streamKey: streamKey, @@ -64,7 +77,8 @@ const Player = (props: PlayerProps) => { onLayerEndpointChange: (endpoint) => setLayerEndpoint(endpoint), onLayerStatus: (status) => setCurrentLayersStatus(status), onStreamStatus: (status) => { - setCurrentStreamStatus(() => status) + setCurrentStreamStatus(status) + onStreamStatusChange?.(streamKey, status) if (!status.isOnline) { setStreamState("Offline") @@ -80,7 +94,8 @@ const Player = (props: PlayerProps) => { setStreamState("Loading") }, onError: () => setStreamState("Error"), - }), [streamKey]) + onChatAdapterChange: (adapter) => onChatAdapterChange?.(streamKey, adapter), + }), [onChatAdapterChange, onStreamStatusChange, streamKey]) const handleEnterFullscreen = () => { const videoElement = videoRef.current as FullscreenElement | null; @@ -107,14 +122,14 @@ const Player = (props: PlayerProps) => { const resetTimer = useCallback((isVisible: boolean) => { - setVideoOverlayVisible(() => isVisible); + setVideoOverlayVisible(isVisible); if (videoOverlayVisibleTimeoutRef) { clearTimeout(videoOverlayVisibleTimeoutRef.current) } videoOverlayVisibleTimeoutRef.current = setTimeout(() => { - setVideoOverlayVisible(() => false) + setVideoOverlayVisible(false) }, 2500) }, []) @@ -139,6 +154,11 @@ const Player = (props: PlayerProps) => { handleEnterFullscreen(); }; + const stopOverlayClickPropagation = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + useEffect(() => { const player = document.getElementById(streamVideoPlayerId) const handleMouseUp = () => resetTimer(true) @@ -170,6 +190,7 @@ const Player = (props: PlayerProps) => { setupPeerConnection() return () => { + onChatAdapterChange?.(streamKey, undefined) player?.removeEventListener('mouseup', handleMouseUp) player?.removeEventListener('mouseenter', handleMouseEnter) player?.removeEventListener('mouseleave', handleMouseLeave) @@ -179,19 +200,20 @@ const Player = (props: PlayerProps) => { currentPeerConnection?.close() clearTimeout(videoOverlayVisibleTimeoutRef.current) } - }, [peerConnectionConfig, resetTimer, streamVideoPlayerId]) + }, [onChatAdapterChange, onStreamStatusChange, peerConnectionConfig, resetTimer, streamKey, streamVideoPlayerId]) return ( -
- -
+
+
+ +
{ {videoElement !== null && (
e.stopPropagation()} + onClick={stopOverlayClickPropagation} + onDoubleClick={stopOverlayClickPropagation} className="bg-blue-950 w-full flex flex-row gap-2 h-1/14 p-1 max-h-8 min-h-8 rounded-md"> @@ -255,24 +278,35 @@ const Player = (props: PlayerProps) => { state={streamState} /> - {!!props.onCloseStream && ( +
+ {!!onToggleChat && ( )} + {!!onCloseStream && ( + + )} +
+
- - {/* Stream MOTD*/} - -
) } diff --git a/web/src/components/player/PlayerPage.tsx b/web/src/components/player/PlayerPage.tsx index 297e6c42..83e4c70d 100644 --- a/web/src/components/player/PlayerPage.tsx +++ b/web/src/components/player/PlayerPage.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import Player from "./Player"; import { useNavigate } from "react-router-dom"; import { CinemaModeContext } from "../../providers/CinemaModeProvider"; @@ -6,6 +6,10 @@ import ModalTextInput from "../shared/ModalTextInput"; import Button from "../shared/Button"; import AvailableStreams from "../selection/AvailableStreams"; import { LocaleContext } from "../../providers/LocaleProvider"; +import ChatPanel from "./components/ChatPanel"; +import { ChatAdapter } from "../../hooks/useChatSession"; +import { StreamMOTD } from "./components/StreamMOTD"; +import { StreamStatus } from "../../providers/StatusProvider"; const PlayerPage = () => { const navigate = useNavigate(); @@ -13,6 +17,15 @@ const PlayerPage = () => { const { cinemaMode, toggleCinemaMode } = useContext(CinemaModeContext); const [streamKeys, setStreamKeys] = useState([ window.location.pathname.substring(1) ]); const [isModalOpen, setIsModelOpen] = useState(false); + const [isChatOpen, setIsChatOpen] = useState(() => localStorage.getItem("chat-open") !== "false"); + const [chatAdapters, setChatAdapters] = useState>({}); + const [streamStatuses, setStreamStatuses] = useState>({}); + const [isDisplayNameModalOpen, setIsDisplayNameModalOpen] = useState(false); + const [chatDisplayName, setChatDisplayName] = useState(() => localStorage.getItem("chatDisplayName") ?? ""); + + useEffect(() => { + localStorage.setItem("chat-open", String(isChatOpen)); + }, [isChatOpen]); const addStream = (streamKey: string) => { if (streamKeys.some((key: string) => key.toLowerCase() === streamKey.toLowerCase())) { @@ -22,6 +35,58 @@ const PlayerPage = () => { setIsModelOpen((prev) => !prev); }; + const setStreamChatAdapter = useCallback((streamKey: string, adapter: ChatAdapter | undefined) => { + setChatAdapters((current) => { + if (current[streamKey] === adapter) { + return current; + } + + return { + ...current, + [streamKey]: adapter, + }; + }); + }, []); + + const setStreamStatus = useCallback((streamKey: string, status: StreamStatus | undefined) => { + setStreamStatuses((current) => { + if (current[streamKey] === status) { + return current; + } + + return { + ...current, + [streamKey]: status, + }; + }); + }, []); + + const removeStream = (streamKey: string) => { + setStreamKeys((prev) => prev.filter((key) => key !== streamKey)); + setChatAdapters((current) => { + const next = { ...current }; + delete next[streamKey]; + return next; + }); + setStreamStatuses((current) => { + const next = { ...current }; + delete next[streamKey]; + return next; + }); + }; + + const saveDisplayName = useCallback((value: string) => { + const trimmedValue = value.trim(); + if (!trimmedValue) { + return; + } + setChatDisplayName(trimmedValue); + localStorage.setItem("chatDisplayName", trimmedValue); + setIsDisplayNameModalOpen(false); + }, []); + + const isSingleStream = streamKeys.length === 1; + return (
{isModalOpen && ( @@ -41,23 +106,57 @@ const PlayerPage = () => { )} + {isDisplayNameModalOpen && ( + + title={locale.chat.modal_display_name_title} + message={locale.chat.modal_display_name_message} + placeholder={locale.chat.modal_display_name_placeholder} + isOpen={isDisplayNameModalOpen} + canCloseOnBackgroundClick + onClose={() => setIsDisplayNameModalOpen(false)} + onAccept={saveDisplayName} + /> + )} +
-
- {streamKeys.map((streamKey) => ( - navigate("/") - : () => - setStreamKeys((prev) => - prev.filter((key) => key !== streamKey), - ) - } - /> - ))} +
+ {streamKeys.map((streamKey, index) => { + const isPrimarySingleStream = isSingleStream && index === 0; + + return ( +
+ + +
+
+ setIsChatOpen((prev) => !prev)} + onChatAdapterChange={setStreamChatAdapter} + onStreamStatusChange={setStreamStatus} + onCloseStream={isPrimarySingleStream ? () => navigate("/") : () => removeStream(streamKey)} + /> +
+ + setIsDisplayNameModalOpen(true)} + /> +
+
+ ); + })}
{/*Footer menu*/} diff --git a/web/src/components/player/components/ChatPanel.tsx b/web/src/components/player/components/ChatPanel.tsx new file mode 100644 index 00000000..6e9cb42e --- /dev/null +++ b/web/src/components/player/components/ChatPanel.tsx @@ -0,0 +1,311 @@ +import { + FormEvent, + memo, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { + ChatBubbleLeftRightIcon, + PencilSquareIcon, + PaperAirplaneIcon, +} from "@heroicons/react/24/outline"; +import { + ChatAdapter, + ChatStatus, + Message, + useChatSession, +} from "../../../hooks/useChatSession"; +import { LocaleContext } from "../../../providers/LocaleProvider"; + +const noop = () => {}; + +type ChatVariant = "sidebar" | "below"; + +interface ChatPanelProps { + streamKey: string; + variant: ChatVariant; + isOpen: boolean; + adapter?: ChatAdapter; + displayName?: string; + onChangeDisplayNameRequested?: () => void; +} + +const getNameColor = (displayName: string) => { + let hash = 0; + for (let i = 0; i < displayName.length; i += 1) { + hash = displayName.charCodeAt(i) + ((hash << 5) - hash); + } + + return `hsl(${Math.abs(hash) % 360}, 70%, 60%)`; +}; + +const ChatMessage = memo(function ChatMessage(props: { message: Message }) { + const { message } = props; + const timestamp = new Date(message.ts).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + return ( +
+
+ + {message.displayName} + + {timestamp} +
+

{message.text}

+
+ ); +}); + +interface ChatComposerProps { + status: ChatStatus; + isSending: boolean; + onNameRequested(): void; + onSend(text: string): Promise; + locale: { + placeholder_input: string; + button_change_display_name_title: string; + button_send_title: string; + }; +} + +const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + const { status, isSending, onNameRequested, onSend, locale } = props; + const [text, setText] = useState(""); + const canSend = + text.trim().length > 0 && !isSending && status === "connected"; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + + if (!text.trim()) { + return; + } + + const sent = await onSend(text); + if (sent) { + setText(""); + } + }; + + return ( +
+
+ setText(event.target.value)} + placeholder={locale.placeholder_input} + className="h-9 flex-1 rounded-md border border-gray-700 bg-gray-800 px-3 text-sm text-gray-100 placeholder:text-gray-400 focus:outline-hidden" + /> + + + + +
+ + {text.length > 1800 && ( +
+ {text.length}/2000 +
+ )} +
+ ); +}); + +const statusColorClass = (status: ChatStatus) => { + if (status === "connected") { + return "bg-green-500"; + } + + if (status === "connecting") { + return "animate-pulse bg-yellow-400"; + } + + return "bg-red-500"; +}; + +const getLocalizedStatus = (status: ChatStatus, locale: { status_connecting: string; status_connected: string; status_error: string; status_disconnected: string }) => { + switch (status) { + case "connecting": + return locale.status_connecting; + case "connected": + return locale.status_connected; + case "error": + return locale.status_error; + case "disconnected": + return locale.status_disconnected; + default: + return status; + } +}; + +const ChatPanel = (props: ChatPanelProps) => { + const { streamKey, variant, isOpen, adapter, displayName, onChangeDisplayNameRequested } = props; + const { locale } = useContext(LocaleContext); + const { messages, status, error, sendMessage } = useChatSession( + streamKey, + adapter, + locale.chat.error_failed_to_connect, + ); + + const [isSending, setIsSending] = useState(false); + const [sendError, setSendError] = useState(null); + + const messageListRef = useRef(null); + const shouldStickToBottomRef = useRef(true); + const firstBatchRef = useRef(true); + + useEffect(() => { + if (!isOpen) { + return; + } + + const node = messageListRef.current; + if (!node) { + return; + } + + if (firstBatchRef.current || shouldStickToBottomRef.current) { + node.scrollTop = node.scrollHeight; + firstBatchRef.current = false; + } + }, [isOpen, messages]); + + useEffect(() => { + firstBatchRef.current = true; + shouldStickToBottomRef.current = true; + }, [streamKey]); + + const onMessageListScroll = () => { + const node = messageListRef.current; + if (!node) { + return; + } + + const distanceToBottom = + node.scrollHeight - node.scrollTop - node.clientHeight; + shouldStickToBottomRef.current = distanceToBottom <= 100; + }; + + const onSend = useCallback( + async (text: string) => { + if (!displayName?.trim()) { + onChangeDisplayNameRequested?.(); + return false; + } + + setIsSending(true); + setSendError(null); + + try { + await sendMessage(text.trim(), displayName!.trim(), locale.chat.error_not_connected); + return true; + } catch (nextError) { + const message = + nextError instanceof Error + ? nextError.message + : locale.chat.error_failed_to_send; + setSendError(message); + return false; + } finally { + setIsSending(false); + } + }, + [displayName, sendMessage, onChangeDisplayNameRequested, locale.chat.error_failed_to_send, locale.chat.error_not_connected], + ); + + const base = + "flex flex-col overflow-hidden rounded-md border border-gray-700 bg-slate-900 text-gray-100 transition-[height,max-height,width,opacity,transform,border-color] duration-200 ease-out"; + const panelClassName = variant === "sidebar" + ? `${base} min-h-0 shrink-0 ${ + isOpen + ? "h-80 w-full opacity-100 lg:absolute lg:top-0 lg:right-0 lg:h-full lg:w-80" + : "h-0 w-full max-h-0 translate-y-1 opacity-0 pointer-events-none border-transparent lg:absolute lg:top-0 lg:right-0 lg:h-full lg:w-0 lg:max-h-none lg:translate-y-0 lg:translate-x-2" + }` + : `${base} ${isOpen ? "h-96 translate-y-0 opacity-100" : "h-0 translate-y-1 border-transparent opacity-0 pointer-events-none"}`; + + return ( +
+
+
+ + {locale.chat.title} +
+ +
+ + {getLocalizedStatus(status, locale.chat)} +
+
+ +
+ {error && ( +
+ {error} +
+ )} + {sendError && ( +
+ {sendError} +
+ )} + + {!error && messages.length === 0 && status === "connected" && ( +
{locale.chat.no_messages_yet}
+ )} + +
+ {messages.map((message) => ( + + ))} +
+
+ + +
+ ); +}; + +export default ChatPanel; diff --git a/web/src/components/player/components/StreamMOTD.tsx b/web/src/components/player/components/StreamMOTD.tsx index 71f0e20f..dac5ee91 100644 --- a/web/src/components/player/components/StreamMOTD.tsx +++ b/web/src/components/player/components/StreamMOTD.tsx @@ -4,14 +4,15 @@ import { LocaleContext } from "../../../providers/LocaleProvider"; interface StreamMOTDProps { isOnline: boolean; motd: string; + className?: string; } export const StreamMOTD = (props: StreamMOTDProps) =>{ - const {isOnline, motd} = props; + const { isOnline, motd, className = '' } = props; const { locale } = useContext(LocaleContext) return ( -
-
+
+
{motd}
@@ -21,6 +22,6 @@ export const StreamMOTD = (props: StreamMOTDProps) =>{ {locale.player.stream_status_offline}
-
-
) +
+
) } diff --git a/web/src/components/player/functions/chatDataChannel.ts b/web/src/components/player/functions/chatDataChannel.ts new file mode 100644 index 00000000..951ea2bd --- /dev/null +++ b/web/src/components/player/functions/chatDataChannel.ts @@ -0,0 +1,241 @@ +import { + ChatAdapter, + ChatStatus, + Message, +} from "../../../hooks/useChatSession"; + +const DATA_CHANNEL_LABEL = "bb-chat-v1"; + +type ChatInbound = { + type: "chat.send"; + clientMsgId: string; + text: string; + displayName: string; +}; + +type ChatHistoryEvent = { + type: "message"; + message: Message; +}; + +type ChatOutbound = + | { type: "chat.connected" } + | { type: "chat.history"; events: ChatHistoryEvent[] } + | { type: "chat.message"; eventId: number; message: Message } + | { type: "chat.ack"; clientMsgId: string } + | { type: "chat.error"; error: string; clientMsgId?: string }; + +interface PendingMessage { + resolve(): void; + reject(error: Error): void; + timeout: ReturnType; +} + +const createMessageId = () => { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +}; + +export class ChatDataChannelAdapter implements ChatAdapter { + private isConnectedToStream = false; + private channel: RTCDataChannel | null = null; + private pending = new Map(); + + private onMessage: ((message: Message) => void) | null = null; + private onStatus: ((status: ChatStatus) => void) | null = null; + private onError: ((error: string) => void) | null = null; + + private setDisconnected(reason: string, errorMessage?: string) { + this.onStatus?.("disconnected"); + if (errorMessage) { + this.onError?.(errorMessage); + } + this.rejectPending(reason); + } + + private settlePending(clientMsgId: string, error?: Error) { + const pendingItem = this.pending.get(clientMsgId); + if (!pendingItem) { + return; + } + + clearTimeout(pendingItem.timeout); + if (error) { + pendingItem.reject(error); + } else { + pendingItem.resolve(); + } + this.pending.delete(clientMsgId); + } + + private handleOpen = () => { + this.onStatus?.("connecting"); + }; + + private handleClose = () => { + this.setDisconnected("Chat disconnected"); + }; + + private handleError = () => { + this.setDisconnected("Chat data channel error", "Chat data channel error"); + }; + + private handleMessage = (event: MessageEvent) => { + let payload: ChatOutbound; + + try { + payload = JSON.parse(event.data) as ChatOutbound; + } catch { + this.onError?.("Invalid chat payload"); + return; + } + + switch (payload.type) { + case "chat.connected": + this.onStatus?.("connected"); + return; + case "chat.history": + payload.events.forEach((entry) => { + if (entry.type === "message") { + this.onMessage?.(entry.message); + } + }); + return; + case "chat.message": + this.onMessage?.(payload.message); + return; + case "chat.ack": { + this.settlePending(payload.clientMsgId); + return; + } + case "chat.error": { + this.onError?.(payload.error); + this.onStatus?.("error"); + + if (payload.clientMsgId) { + this.settlePending(payload.clientMsgId, new Error(payload.error)); + } + + return; + } + default: + this.onError?.("Unsupported chat payload"); + } + }; + + attachChannel(channel: RTCDataChannel) { + this.detachChannel(); + + this.channel = channel; + this.channel.addEventListener("open", this.handleOpen); + this.channel.addEventListener("close", this.handleClose); + this.channel.addEventListener("error", this.handleError); + this.channel.addEventListener("message", this.handleMessage); + + this.onStatus?.("connecting"); + } + + detachChannel() { + if (!this.channel) { + return; + } + + this.rejectPending("Chat disconnected"); + + this.channel.removeEventListener("open", this.handleOpen); + this.channel.removeEventListener("close", this.handleClose); + this.channel.removeEventListener("error", this.handleError); + this.channel.removeEventListener("message", this.handleMessage); + this.channel = null; + this.isConnectedToStream = false; + } + + private rejectPending(reason: string) { + this.pending.forEach((item) => { + clearTimeout(item.timeout); + item.reject(new Error(reason)); + }); + this.pending.clear(); + } + + async connect(streamKey: string): Promise { + this.isConnectedToStream = streamKey.length > 0; + } + + subscribe( + onMessage: (message: Message) => void, + onStatus: (status: ChatStatus) => void, + onError: (error: string) => void, + ): () => void { + this.onMessage = onMessage; + this.onStatus = onStatus; + this.onError = onError; + + if (this.channel && this.channel.readyState !== "closed") { + this.onStatus("connecting"); + } + + return () => { + if (this.onMessage === onMessage) { + this.onMessage = null; + } + + if (this.onStatus === onStatus) { + this.onStatus = null; + } + + if (this.onError === onError) { + this.onError = null; + } + }; + } + + async send(text: string, displayName: string): Promise { + if (!this.channel || this.channel.readyState !== "open") { + throw new Error("Chat data channel is not open"); + } + + if (!this.isConnectedToStream) { + throw new Error("Chat is not connected to stream"); + } + + const clientMsgId = createMessageId(); + const payload: ChatInbound = { + type: "chat.send", + clientMsgId, + text, + displayName, + }; + + const rawPayload = JSON.stringify(payload); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.settlePending(clientMsgId, new Error("Chat message timed out")); + }, 10_000); + + this.pending.set(clientMsgId, { resolve, reject, timeout }); + + try { + this.channel?.send(rawPayload); + } catch (error) { + const sendError = + error instanceof Error + ? error + : new Error("Failed to send chat message"); + this.settlePending( + clientMsgId, + sendError, + ); + } + }); + } +} + +export { DATA_CHANNEL_LABEL }; diff --git a/web/src/components/player/functions/peerconnection.tsx b/web/src/components/player/functions/peerconnection.tsx index 044858a8..17cea9a3 100644 --- a/web/src/components/player/functions/peerconnection.tsx +++ b/web/src/components/player/functions/peerconnection.tsx @@ -1,6 +1,8 @@ import { parseLinkHeader } from "@web3-storage/parse-link-header"; import { StreamStatus } from "../../../providers/StatusProvider"; import { RefObject } from "react"; +import { ChatAdapter } from "../../../hooks/useChatSession"; +import { ChatDataChannelAdapter, DATA_CHANNEL_LABEL } from "./chatDataChannel"; export interface CurrentLayersMessage { id: string, @@ -34,6 +36,7 @@ enum SetupPeerConnectionStateChange { ONLINE, OFFLINE } + export interface SetupPeerConnectionProps { streamKey: string, videoRef: RefObject, @@ -47,6 +50,7 @@ export interface SetupPeerConnectionProps { onLayerEndpointChange?: (endpoint: string) => void, onStateChange: (state: SetupPeerConnectionStateChange) => void, onStreamRestart: () => void, + onChatAdapterChange?: (adapter: ChatAdapter | undefined) => void, } const stopVideoTrack = (videoElement: HTMLVideoElement | null) => { @@ -74,7 +78,8 @@ export async function PeerConnectionSetup(props: SetupPeerConnectionProps): Prom onVideoLayerChange, onLayerEndpointChange, onStateChange, - onError } = props + onError, + onChatAdapterChange } = props if (videoRef.current === null){ throw new Error("PeerConnection.VideoRef is null") @@ -85,6 +90,10 @@ export async function PeerConnectionSetup(props: SetupPeerConnectionProps): Prom // Create peerconnection const peerConnection = await createPeerConnection() + const chatDataChannel = peerConnection.createDataChannel(DATA_CHANNEL_LABEL) + const chatAdapter = new ChatDataChannelAdapter() + chatAdapter.attachChannel(chatDataChannel) + onChatAdapterChange?.(chatAdapter) // Config peerConnection.addTransceiver('audio', { direction: 'recvonly' }) @@ -138,6 +147,8 @@ export async function PeerConnectionSetup(props: SetupPeerConnectionProps): Prom evtSource.onerror = (ev: Event) => { console.error("PeerConnection.EventSource", ev) evtSource.close(); + chatAdapter.detachChannel() + onChatAdapterChange?.(undefined) onStateChange(SetupPeerConnectionStateChange.OFFLINE) } @@ -146,6 +157,8 @@ export async function PeerConnectionSetup(props: SetupPeerConnectionProps): Prom console.log("PeerConnection.EventSource", "Reset Stream", streamKey) evtSource.close() + chatAdapter.detachChannel() + onChatAdapterChange?.(undefined) peerConnection.close() onStreamRestart() @@ -176,6 +189,17 @@ export async function PeerConnectionSetup(props: SetupPeerConnectionProps): Prom type: 'answer' }).catch((err) => console.error("PeerConnection.RemoteDescription", err)) + peerConnection.addEventListener('connectionstatechange', () => { + if ( + peerConnection.connectionState === 'closed' || + peerConnection.connectionState === 'failed' || + peerConnection.connectionState === 'disconnected' + ) { + chatAdapter.detachChannel() + onChatAdapterChange?.(undefined) + } + }) + return peerConnection; } diff --git a/web/src/components/shared/ModalTextInput.tsx b/web/src/components/shared/ModalTextInput.tsx index 853cde67..42866e86 100644 --- a/web/src/components/shared/ModalTextInput.tsx +++ b/web/src/components/shared/ModalTextInput.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useRef } from "react"; +import React, { useContext, useEffect, useRef } from "react"; import { LocaleContext } from "../../providers/LocaleProvider"; import ErrorMessagePanel from "./ErrorMessagePanel"; @@ -37,67 +37,66 @@ export default function ModalTextInput( } return ( -
-
{ - if (props.canCloseOnBackgroundClick) { - props.onDeny?.(); - props.onClose?.(); - } - }} - > -
e.stopPropagation()} - > -

{props.title}

-

{props.message}

- - { /*Error message*/} - {props.errorMessage != undefined && props.errorMessage !== "" && ( - - )} +
+
{ + if (props.canCloseOnBackgroundClick) { + props.onDeny?.(); + props.onClose?.(); + } + }} + /> +
e.stopPropagation()} + > +

{props.title}

+

{props.message}

- {/*Input*/} - evt.key === "Enter" ? props.onAccept?.(valueRef.current?.value as T) : null} - autoFocus + { /*Error message*/} + {props.errorMessage != undefined && props.errorMessage !== "" && ( + + )} - {/* Optional children */} - {props.children && (
- {props.children} -
)} + {/*Input*/} + evt.key === "Enter" ? props.onAccept?.(valueRef.current?.value as T) : null} + autoFocus + /> - {/*Buttons*/} -
- {props.onAccept !== null && ( - - )} - -
+ )} +
diff --git a/web/src/hooks/useChatSession.ts b/web/src/hooks/useChatSession.ts new file mode 100644 index 00000000..2270f7d8 --- /dev/null +++ b/web/src/hooks/useChatSession.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from "react"; + +export type ChatStatus = "connecting" | "connected" | "error" | "disconnected"; + +export interface Message { + id: string; + ts: number; + text: string; + displayName: string; +} + +export interface ChatAdapter { + connect(streamKey: string): Promise; + subscribe( + onMessage: (message: Message) => void, + onStatus: (status: ChatStatus) => void, + onError: (error: string) => void, + ): () => void; + send(text: string, displayName: string): Promise; +} + +const MAX_MESSAGES = 1000; + +const appendUniqueCapped = (current: Message[], message: Message): Message[] => { + if (current.some((existing) => existing.id === message.id)) { + return current; + } + + const next = [...current, message]; + if (next.length <= MAX_MESSAGES) { + return next; + } + + return next.slice(next.length - MAX_MESSAGES); +}; + +export const useChatSession = (streamKey: string, adapter?: ChatAdapter, connectionErrorMessage?: string) => { + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState(adapter ? "connecting" : "disconnected"); + const [error, setError] = useState(null); + + const [session, setSession] = useState({ adapter, streamKey }); + if (session.adapter !== adapter || session.streamKey !== streamKey) { + setSession({ adapter, streamKey }); + setMessages([]); + setError(null); + setStatus(adapter ? "connecting" : "disconnected"); + } + + useEffect(() => { + if (!adapter) { + return; + } + + let unsubscribe = () => {}; + let stopped = false; + + unsubscribe = adapter.subscribe( + (message) => { + if (stopped) { + return; + } + + setMessages((current) => appendUniqueCapped(current, message)); + }, + (nextStatus) => { + if (!stopped) { + setStatus(nextStatus); + } + }, + (nextError) => { + if (!stopped) { + setError(nextError); + setStatus("error"); + } + }, + ); + + adapter.connect(streamKey).catch((connectError) => { + if (!stopped) { + const message = + connectError instanceof Error + ? connectError.message + : connectionErrorMessage ?? "Failed to connect chat"; + setError(message); + setStatus("error"); + } + }); + + return () => { + stopped = true; + unsubscribe(); + }; + }, [adapter, streamKey, connectionErrorMessage]); + + const sendMessage = useCallback( + async (text: string, displayName: string, notConnectedErrorMessage?: string) => { + if (!adapter) { + throw new Error(notConnectedErrorMessage ?? "Chat is not connected"); + } + + setError(null); + await adapter.send(text, displayName); + }, + [adapter], + ); + + return { messages, status, error, sendMessage }; +}; diff --git a/web/src/locale/da.ts b/web/src/locale/da.ts index be6ab50a..615f1b66 100644 --- a/web/src/locale/da.ts +++ b/web/src/locale/da.ts @@ -181,5 +181,23 @@ const locale_da: localeInterface = { shared_component_text_input_dialog: { button_accept: "Acceptér" }, + + chat: { + title: "Chat", + placeholder_input: "Skriv en besked", + button_change_display_name_title: "Skift visningsnavn", + button_send_title: "Send besked", + status_connecting: "forbinder", + status_connected: "forbundet", + status_error: "fejl", + status_disconnected: "afbrudt", + no_messages_yet: "Ingen chatbeskeder endnu.", + error_failed_to_connect: "Kunne ikke forbinde til chat", + error_not_connected: "Chat er ikke forbundet", + error_failed_to_send: "Kunne ikke sende besked", + modal_display_name_title: "Visningsnavn", + modal_display_name_message: "Indstil dit visningsnavn til chat", + modal_display_name_placeholder: "Indtast visningsnavn", + }, } export default locale_da diff --git a/web/src/locale/en.ts b/web/src/locale/en.ts index 929a43d4..670032cf 100644 --- a/web/src/locale/en.ts +++ b/web/src/locale/en.ts @@ -181,5 +181,23 @@ const locale_en: localeInterface = { shared_component_text_input_dialog: { button_accept: "Accept" }, + + chat: { + title: "Chat", + placeholder_input: "Write a message", + button_change_display_name_title: "Change display name", + button_send_title: "Send message", + status_connecting: "connecting", + status_connected: "connected", + status_error: "error", + status_disconnected: "disconnected", + no_messages_yet: "No chat messages yet.", + error_failed_to_connect: "Failed to connect chat", + error_not_connected: "Chat is not connected", + error_failed_to_send: "Failed to send message", + modal_display_name_title: "Display name", + modal_display_name_message: "Set your display name for chat", + modal_display_name_placeholder: "Enter display name", + }, } export default locale_en diff --git a/web/src/locale/localeInterface.ts b/web/src/locale/localeInterface.ts index d6f1b84b..2ae7b41c 100644 --- a/web/src/locale/localeInterface.ts +++ b/web/src/locale/localeInterface.ts @@ -186,5 +186,23 @@ export interface localeInterface { yes: string, no: string + }, + + chat: { + title: string, + placeholder_input: string, + button_change_display_name_title: string, + button_send_title: string, + status_connecting: string, + status_connected: string, + status_error: string, + status_disconnected: string, + no_messages_yet: string, + error_failed_to_connect: string, + error_not_connected: string, + error_failed_to_send: string, + modal_display_name_title: string, + modal_display_name_message: string, + modal_display_name_placeholder: string, } }