From 94ecdece9518a8e58e51a84b8d8b1618f1cb0d6c Mon Sep 17 00:00:00 2001 From: P-man2976 Date: Sat, 4 Nov 2023 10:07:06 +0900 Subject: [PATCH] TLChat / websocket utils WIP --- packages/react/package-lock.json | 97 ++++++++- packages/react/package.json | 3 + .../react/src/components/chat/LiveChat.tsx | 2 +- .../LanguagePicker.tsx} | 6 +- .../components/player/PlayerDescription.tsx | 8 +- .../react/src/components/tldex/TLChat.tsx | 66 +++++- packages/react/src/hooks/useChatDB.ts | 178 ++++++++++++++++ packages/react/src/hooks/useSocket.ts | 201 ++++++++++++++++++ packages/react/src/lib/ChatDB.ts | 33 +++ packages/react/src/lib/socket.ts | 41 ++++ packages/react/src/lib/utils.ts | 16 ++ packages/react/src/routes/home.tsx | 8 +- packages/react/src/routes/watch.tsx | 34 ++- packages/react/src/store/chat.ts | 50 +++++ packages/react/src/store/i18n.ts | 12 +- packages/react/src/store/player.ts | 4 +- packages/react/src/store/socket.ts | 25 +++ packages/react/src/store/tldex.ts | 43 ++++ packages/react/src/types/lang.ts | 4 - packages/react/src/types/socket.d.ts | 53 +++++ 20 files changed, 846 insertions(+), 38 deletions(-) rename packages/react/src/components/{languae/languagePicker.tsx => language/LanguagePicker.tsx} (93%) create mode 100644 packages/react/src/hooks/useChatDB.ts create mode 100644 packages/react/src/hooks/useSocket.ts create mode 100644 packages/react/src/lib/ChatDB.ts create mode 100644 packages/react/src/lib/socket.ts create mode 100644 packages/react/src/store/chat.ts create mode 100644 packages/react/src/store/socket.ts create mode 100644 packages/react/src/store/tldex.ts delete mode 100644 packages/react/src/types/lang.ts create mode 100644 packages/react/src/types/socket.d.ts diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index dc8dd2be8..4a76f15b1 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -63,6 +63,8 @@ "react-router-dom": "^6.17.0", "react-use": "^17.4.0", "react-virtuoso": "^4.6.2", + "socket.io-client": "^4.7.2", + "sorted-array-functions": "^1.3.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", "use-seconds": "^1.7.0", @@ -78,6 +80,7 @@ "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", "@types/react-grid-layout": "^1.3.4", + "@types/sorted-array-functions": "^1.3.1", "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", "@unocss/eslint-config": "^0.56.5", @@ -2883,6 +2886,11 @@ } } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "node_modules/@swc-jotai/debug-label": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@swc-jotai/debug-label/-/debug-label-0.1.0.tgz", @@ -3311,6 +3319,12 @@ "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", "dev": true }, + "node_modules/@types/sorted-array-functions": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@types/sorted-array-functions/-/sorted-array-functions-1.3.1.tgz", + "integrity": "sha512-pQbyz6esWUYmt1rJVjKxJGBt8NU0Xv+VDmd4cgfdljZDEc+A6ysbFbCeD6XJuOpv1q/wrSkBepDLelUEUJGlHA==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.8.0.tgz", @@ -4924,7 +4938,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -5109,6 +5122,46 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/engine.io-client": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.2.tgz", + "integrity": "sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", + "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/errno": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", @@ -7191,8 +7244,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/mz": { "version": "2.7.0", @@ -8905,6 +8957,37 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/socket.io-client": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.2.tgz", + "integrity": "sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9924,6 +10007,14 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/packages/react/package.json b/packages/react/package.json index 1214e9222..624d2f164 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -70,6 +70,8 @@ "react-router-dom": "^6.17.0", "react-use": "^17.4.0", "react-virtuoso": "^4.6.2", + "socket.io-client": "^4.7.2", + "sorted-array-functions": "^1.3.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", "use-seconds": "^1.7.0", @@ -85,6 +87,7 @@ "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", "@types/react-grid-layout": "^1.3.4", + "@types/sorted-array-functions": "^1.3.1", "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", "@unocss/eslint-config": "^0.56.5", diff --git a/packages/react/src/components/chat/LiveChat.tsx b/packages/react/src/components/chat/LiveChat.tsx index d22d1d2a3..f9d3b9a21 100644 --- a/packages/react/src/components/chat/LiveChat.tsx +++ b/packages/react/src/components/chat/LiveChat.tsx @@ -28,7 +28,7 @@ export function LiveChat({ if (needExtension) return ( -
+
{t("views.watch.chat.archiveNeedExtension", { 0: ( diff --git a/packages/react/src/components/languae/languagePicker.tsx b/packages/react/src/components/language/LanguagePicker.tsx similarity index 93% rename from packages/react/src/components/languae/languagePicker.tsx rename to packages/react/src/components/language/LanguagePicker.tsx index d18b14d13..960ecb316 100644 --- a/packages/react/src/components/languae/languagePicker.tsx +++ b/packages/react/src/components/language/LanguagePicker.tsx @@ -1,6 +1,6 @@ import React from "react"; import { CLIPPER_LANGS } from "@/lib/consts"; -import { currentLangAtom } from "@/store/i18n"; +import { clipLangAtom } from "@/store/i18n"; import { Command, CommandInput, @@ -18,10 +18,10 @@ import { t } from "i18next"; import { cn } from "@/lib/utils"; export const LanguageSelector = () => { - const [currentLang, setCurrentLang] = useAtom(currentLangAtom); + const [currentLang, setCurrentLang] = useAtom(clipLangAtom); const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState(""); - console.log(currentLang); + return ( diff --git a/packages/react/src/components/player/PlayerDescription.tsx b/packages/react/src/components/player/PlayerDescription.tsx index 233466e81..e5ddcc6e2 100644 --- a/packages/react/src/components/player/PlayerDescription.tsx +++ b/packages/react/src/components/player/PlayerDescription.tsx @@ -14,7 +14,7 @@ export function PlayerDescription({ const [isExpanded, setIsExpanded] = useState(defaultExpanded); return ( -
+
{description.split(/\r\n|\r|\n/).length > lines && ( - )} diff --git a/packages/react/src/components/tldex/TLChat.tsx b/packages/react/src/components/tldex/TLChat.tsx index 551b1a5a4..a2c1c126c 100644 --- a/packages/react/src/components/tldex/TLChat.tsx +++ b/packages/react/src/components/tldex/TLChat.tsx @@ -1,3 +1,65 @@ -export function TLChat() { - return <>; +import { useSocket } from "@/hooks/useSocket"; +import { formatDuration } from "@/lib/time"; +import { cn } from "@/lib/utils"; +import { roomsAtom } from "@/store/chat"; +import { tldexStateAtom } from "@/store/tldex"; +import { useAtomValue } from "jotai"; +import { + DetailedHTMLProps, + HTMLAttributes, + forwardRef, + useEffect, +} from "react"; +import { Virtuoso } from "react-virtuoso"; + +interface TLChatProps { + videoId: string; + isArchive: boolean; } + +export function TLChat({ videoId, isArchive }: TLChatProps) { + const tldexState = useAtomValue(tldexStateAtom); + const roomID: RoomIDString = `${videoId}/${tldexState.liveTlLang}`; + const { chatDB } = useSocket(roomID); + const { messages } = useAtomValue(roomsAtom(roomID)); + + useEffect(() => { + chatDB.loadMessages(isArchive ? 0 : 30); + }, []); + + return ( + chatDB.loadMessages(30)} + data={messages} + itemContent={(_, { message, name, video_offset }) => ( +
+ + {name} + +
+ + {formatDuration(video_offset * 1000)} + + {message} +
+
+ )} + /> + ); +} + +const TLChatItem = forwardRef< + HTMLDivElement, + DetailedHTMLProps, HTMLDivElement> +>((props, ref) => ( +
+)); diff --git a/packages/react/src/hooks/useChatDB.ts b/packages/react/src/hooks/useChatDB.ts new file mode 100644 index 000000000..58f540c43 --- /dev/null +++ b/packages/react/src/hooks/useChatDB.ts @@ -0,0 +1,178 @@ +import { useAtom, useAtomValue, useStore } from "jotai"; +import { useClient } from "./useClient"; +import { tldexStateAtom } from "@/store/tldex"; +import { roomToLang, roomToVideoID, toParsedMessage } from "@/lib/socket"; +import { roomsAtom, videoToRoomAtom } from "@/store/chat"; +import { useEffect } from "react"; + +export function useChatDB(roomId: RoomIDString) { + const client = useClient(); + const store = useStore(); + const tldexState = useAtomValue(tldexStateAtom); + const [room, setRoom] = useAtom(roomsAtom(roomId)); + const [videoToRoom, setVideoToRoom] = useAtom( + videoToRoomAtom(roomToVideoID(roomId)), + ); + + useEffect(() => { + setVideoToRoom({ + type: "add", + value: roomId, + }); + + () => { + setVideoToRoom({ + type: "del", + value: roomId, + }); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** + * Compares two ParsedMessage objects based on their timestamps. + * + * @param {ParsedMessage} a - The first ParsedMessage to compare. + * @param {ParsedMessage} b - The second ParsedMessage to compare. + * @return {number} Returns 1 if a is greater than b, -1 if a is less than b, + * and 0 if a and b are equal. + */ + function ParsedMessageComparator(a: ParsedMessage, b: ParsedMessage) { + if (a.timestamp > b.timestamp) return 1; + if (a.timestamp < b.timestamp) return -1; + return 0; + } + + /** + * Compares two ParsedMessage objects based on their relative offsets. + * + * @param {ParsedMessage} a - The first ParsedMessage to compare. + * @param {ParsedMessage} b - The second ParsedMessage to compare. + * @return {number} Returns 1 if a is greater than b, -1 if a is less than b, + * and 0 if a and b are equal. + */ + function ParsedMessageOFFSETComparator(a: ParsedMessage, b: ParsedMessage) { + if (a.video_offset > b.video_offset) return 1; + if (a.video_offset < b.video_offset) return -1; + return 0; + } + + function sortRoom() { + console.log("sorting...", room); + + (room.messages as ParsedMessage[]).sort(ParsedMessageComparator); + } + + /** + * Optimized method for adding message to a chatroom. + * @param message the message content. + */ + function addMessage(message: ParsedMessage) { + setRoom((currentRoom) => ({ + ...currentRoom, + messages: [...currentRoom.messages, message].sort( + ParsedMessageComparator, + ), + })); + // if (!ChatDB.checkArrayIsUnique(this.room as ParsedMessage[])) { + // this.rooms.set( + // room, + // ChatDB.distinctSortedArray(this.room as ParsedMessage[]) + // ); + // } + } + + /** + * Optimized method for adding many messages to a chatroom. + * @param messages the messages to add. + */ + function addMessages(messages: ParsedMessage[]) { + setRoom((currentRoom) => ({ + ...currentRoom, + messages: [...currentRoom.messages, ...messages].sort( + ParsedMessageComparator, + ), + })); + // if (!ChatDB.checkArrayIsUnique(this.room as ParsedMessage[])) { + // this.rooms.set( + // room, + // ChatDB.distinctSortedArray(this.room as ParsedMessage[]) + // ); + // } + } + + /** + * Updates the current offset of a video, + * @param elapsed elapsed time in seconds + * @param absolute the elapsed time + available_at time + */ + function updateRoomElapsed(elapsed: number, absolute: number | undefined) { + videoToRoom.forEach((roomId) => { + store.set(roomsAtom(roomId), (currentRoom) => ({ + ...currentRoom, + elapsed, + absolute, + })); + }); + } + + /** + * + * @param partial how many to load if partially loading. + */ + function loadMessages(partial?: number) { + console.log("[Load message] room:", room, "partial:", partial); + if (room.state.loading) return; + const prior = room?.messages?.[0]?.timestamp; + + const videoId = roomToVideoID(roomId); + + const params = { + lang: roomToLang(roomId), + verified: tldexState.liveTlShowVerified, + moderator: tldexState.liveTlShowModerator, + vtuber: tldexState.liveTlShowVtuber, + limit: partial, + ...(prior && { before: prior }), + }; + + setRoom((currentRoom) => ({ + ...currentRoom, + state: { loading: true, completed: false }, + })); + + client + .get(`/api/v2/videos/${videoId}/chats`, { params }) + .then((data) => { + addMessages(data.map((x) => toParsedMessage(x, videoId))); + setRoom((currentRoom) => ({ + ...currentRoom, + state: { + completed: data.length !== partial, + loading: false, + }, + })); + }) + .catch((e) => { + console.error(e); + setRoom((currentRoom) => ({ + ...currentRoom, + state: { + completed: false, + loading: false, + }, + })); + }); + } + + return { + ParsedMessageComparator, + ParsedMessageOFFSETComparator, + sortRoom, + addMessage, + addMessages, + updateRoomElapsed, + loadMessages, + }; +} diff --git a/packages/react/src/hooks/useSocket.ts b/packages/react/src/hooks/useSocket.ts new file mode 100644 index 000000000..8539db778 --- /dev/null +++ b/packages/react/src/hooks/useSocket.ts @@ -0,0 +1,201 @@ +import { io, Socket } from "socket.io-client"; +import { roomToLang, roomToVideoID, toParsedMessage } from "../lib/socket"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { debounce } from "@/lib/utils"; +import { useAtom } from "jotai"; +import { roomReferenceCounterAtom, subscribedRoomsAtom } from "@/store/socket"; +import { useChatDB } from "./useChatDB"; + +const API_BASE_URL = `${window.location.origin}`; + +// socket io events: + +// type SocketIOEvents = [ +// "connect", +// "error", +// "disconnect", +// "reconnect", +// "reconnect_attempt", +// "reconnecting", +// "reconnect_error", +// "reconnect_failed", +// "connect_error", +// "connect_timeout", +// "connecting", +// "ping", +// "pong", +// ][number]; + +interface SubscribePayload { + video_id: string; + lang: string; +} + +interface SubscribeSuccessPayload { + id: string; //video_id + lang: string; + live_viewers?: number; + status?: VideoStatus; + start_actual?: string; + handled_chats?: number; +} + +interface SubscribeErrorPayload { + id: string; + message: string; +} + +interface UnsubscribePayload { + id: string; + lang?: string; +} + +interface ServerToClientEvents { + subscribeError: (obj: SubscribeErrorPayload) => void; + subscribeSuccess: (obj: SubscribeSuccessPayload) => void; + unsubscribeSuccess: (obj: UnsubscribePayload) => void; + [key: RoomIDString]: (obj: TldexPayload) => void; +} + +interface ClientToServerEvents { + subscribe: (payload: SubscribePayload) => void; + unsubscribe: (payload: SubscribePayload) => void; +} + +const IDLE_DISCONNECT_TIME = 5000; + +function log(...args: unknown[]) { + console.log("[Socket]", ...args); +} + +const socket: Socket = io( + API_BASE_URL, + { + reconnectionAttempts: 10, + reconnectionDelay: 5000, + reconnectionDelayMax: 20000, + transports: ["websocket"], + upgrade: true, + path: "/api/socket.io/", + secure: true, + autoConnect: false, + }, +); + +export function useSocket(roomId: RoomIDString) { + const chatDB = useChatDB(roomId); + const [subscribedRooms, setSubscribedRooms] = useAtom(subscribedRoomsAtom); + const [roomReferenceCounter, setRoomReferenceCounter] = useAtom( + roomReferenceCounterAtom(roomId), + ); + const [isConnected, setIsConnected] = useState(false); + + const videoId = useMemo(() => roomToVideoID(roomId), [roomId]); + const lang = useMemo(() => roomToLang(roomId), [roomId]); + + // Debounced socket disconnect if nothing is connected + const cleanup = debounce(() => { + socket.disconnect(); + log("No rooms are active. Disconnecting from socket"); + }, IDLE_DISCONNECT_TIME); + + const handleMessage = useCallback( + (payload: TldexPayload) => { + if ("message" in payload) { + const parsed = toParsedMessage(payload, videoId); + chatDB.addMessage(parsed); + } + }, + [chatDB, videoId], + ); + + useEffect(() => { + const onConnect = () => setIsConnected(true); + const onDisconnect = () => setIsConnected(false); + const onSubscribeSuccess = (payload: SubscribeSuccessPayload) => { + log("Subscribed to", payload); + setSubscribedRooms({ + type: "add", + value: `${payload.id}/${payload.lang ?? ""}`, + }); + }; + const onUnsubscribeSuccess = (payload: UnsubscribePayload) => { + log("Unsubscribed to", payload); + setSubscribedRooms({ + type: "del", + value: `${payload.id}/${payload.lang ?? ""}`, + }); + if (subscribedRooms.size) cleanup(); + }; + + socket.on("connect", onConnect); + socket.on("disconnect", onDisconnect); + // socket.on("reconnect_attempt", (attempt: number) => {}); + socket.on("subscribeSuccess", onSubscribeSuccess); + // socket.on("subscribeError", (payload) => {}); + socket.on("unsubscribeSuccess", onUnsubscribeSuccess); + + if (subscribedRooms.has(roomId) || subscribedRooms.has(`${videoId}/`)) { + // Already subscribed + log("Already subscribed. Abort sending subscription request."); + setRoomReferenceCounter(roomReferenceCounter + 1); + return; + } + if (!socket.connected) { + log("Connecting to socket"); + socket.connect(); + } + socket.emit("subscribe", { + video_id: videoId, + lang, + }); + log("Sent subscription request: ", { videoId, lang }); + socket.on(roomId, handleMessage); + setRoomReferenceCounter(1); + + return () => { + // If no rooms are active, unsubscribe + setRoomReferenceCounter(roomReferenceCounter - 1); + if (roomReferenceCounter !== 1) { + log("There are still active listeners, aborting full unsubscribe"); + } else { + socket.emit("unsubscribe", { + video_id: videoId, + lang, + }); + socket.off(roomId); + } + + // If any sockets are active, disconnect + if (subscribedRooms.size === 1) { + socket.disconnect(); + log("Disconnecting from socket"); + } else { + log("There are still active listeners, aborting full unsubscribe"); + log( + "Found", + subscribedRooms.size, + "rooms active", + subscribedRooms.values(), + ); + } + + // Unsubscribe event listeners + socket.off("connect", onConnect); + socket.off("disconnect", onDisconnect); + socket.off("subscribeSuccess", onSubscribeSuccess); + socket.off("unsubscribeSuccess", onUnsubscribeSuccess); + socket.off(roomId, handleMessage); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + subscribedRooms, + socket, + isConnected, + activeSockets: roomReferenceCounter, + chatDB, + }; +} diff --git a/packages/react/src/lib/ChatDB.ts b/packages/react/src/lib/ChatDB.ts new file mode 100644 index 000000000..92b4609bc --- /dev/null +++ b/packages/react/src/lib/ChatDB.ts @@ -0,0 +1,33 @@ +/** + * Determines if a message is currently visible based on the elapsed time or the absolute timestamp. + * + * @param {ParsedMessage} message - The parsed message to check. + * @param {number} [elapsed] - The elapsed time in seconds. + * @param {number} [absolute] - The absolute timestamp in seconds. + * @return {boolean} - `true` if the message is currently visible, `false` otherwise. + */ +export function isMessageCurrent( + message: ParsedMessage, + elapsed?: number, + absolute?: number, +): boolean { + const duration = +(message.duration || 4000) / 1000; + if (message.video_offset && elapsed) { + // console.debug( + // `[${message.video_offset}] [${ + // message.video_offset < elapsed + // }] [${elapsed}] [${elapsed <= message.video_offset + duration}] [${ + // message.video_offset + duration + // }]` + // ); + + return ( + message.video_offset < elapsed && + elapsed <= message.video_offset + duration + ); + } else if (absolute) { + const timestamp = +message.timestamp / 1000; + return timestamp < absolute && absolute <= timestamp + duration; + } + return false; +} diff --git a/packages/react/src/lib/socket.ts b/packages/react/src/lib/socket.ts new file mode 100644 index 000000000..1261a37c7 --- /dev/null +++ b/packages/react/src/lib/socket.ts @@ -0,0 +1,41 @@ +import { TLLanguageCode } from "./consts"; + +export function roomToVideoID(room: RoomIDString): string { + return room.split("/")[0]; +} + +export function roomToLang(room: RoomIDString): TLLanguageCode { + return room.split("/")[1] as TLLanguageCode; +} + +/** + * Parses and augments message body with parsed value and key. + * @param msg + * @param relativeTsAnchor + * @returns + */ +export function toParsedMessage( + msg: TLDexMessage, + video_id?: string, +): ParsedMessage { + msg.timestamp = +msg.timestamp; + const parsed: ParsedMessage = { + ...msg, + // ...(relativeTsAnchor && { relativeMs: msg.timestamp - relativeTsAnchor }), + timestamp: +msg.timestamp, + key: msg.name + msg.timestamp + msg.message, + parsed: "", + duration: +(msg.duration ?? msg.message.length * 65 + 1800), + video_id, + }; + // Check if there's any emojis represented as URLs formatted by backend + if (msg.message.includes("https://") && !("parsed" in msg)) { + // match a :HUMU:https:// + const regex = + /(\S+)(https:\/\/(yt\d+\.ggpht\.com\/[a-zA-Z0-9_\-=/]+-c-k-nd|www\.youtube\.com\/[a-zA-Z0-9_\-=/]+\.svg))/gi; + parsed.parsed = msg.message + .replace(/<([^>]*)>/g, "($1)") + .replace(regex, ''); + } + return parsed; +} diff --git a/packages/react/src/lib/utils.ts b/packages/react/src/lib/utils.ts index 411e8b6fc..bc4dc7b0e 100644 --- a/packages/react/src/lib/utils.ts +++ b/packages/react/src/lib/utils.ts @@ -21,3 +21,19 @@ export function makeYtThumbnailUrl(id: string, size: VideoCardSize) { return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; } } + +export function debounce unknown>( + func: T, + wait?: number, + immediate?: boolean, +) { + let timeout: NodeJS.Timeout | undefined; + return function (...args: unknown[]) { + clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = undefined; + if (!immediate) func.apply({}, args); + }, wait); + if (immediate && !timeout) func.apply({}, args); + }; +} diff --git a/packages/react/src/routes/home.tsx b/packages/react/src/routes/home.tsx index fa8b66742..b5b5bbd6f 100644 --- a/packages/react/src/routes/home.tsx +++ b/packages/react/src/routes/home.tsx @@ -17,8 +17,8 @@ import { useSearchParams, } from "react-router-dom"; import { VirtuosoGrid } from "react-virtuoso"; -import { currentLangAtom } from "@/store/i18n"; -import { LanguageSelector } from "@/components/languae/languagePicker"; +import { clipLangAtom } from "@/store/i18n"; +import { LanguageSelector } from "@/components/language/LanguagePicker"; export function Home() { const navigate = useNavigate(); @@ -32,7 +32,7 @@ export function Home() { { org, type: ["placeholder", "stream"], include: ["mentions"] }, { refetchInterval: 1000 * 60 * 5, enabled: tab === "live" }, ); - const [currentLang] = useAtom(currentLangAtom); + const clipLang = useAtomValue(clipLangAtom); const { data: archives, @@ -67,7 +67,7 @@ export function Home() { max_upcoming_hours: 1, paginated: true, limit: 32, - lang: [`${currentLang.value}`], + lang: [`${clipLang.value}`], }, { refetchInterval: 1000 * 60 * 5, diff --git a/packages/react/src/routes/watch.tsx b/packages/react/src/routes/watch.tsx index b6c2141d1..59eb6bcce 100644 --- a/packages/react/src/routes/watch.tsx +++ b/packages/react/src/routes/watch.tsx @@ -6,6 +6,7 @@ import { PlayerChannelCard } from "@/components/player/PlayerChannelCard"; import { PlayerDescription } from "@/components/player/PlayerDescription"; import { PlayerRecommendations } from "@/components/player/PlayerRecommendations"; import { PlayerStats } from "@/components/player/PlayerStats"; +import { TLChat } from "@/components/tldex/TLChat"; import { cn } from "@/lib/utils"; import { useVideo } from "@/services/video.service"; import { @@ -13,7 +14,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/shadcn/ui/collapsible"; -import { currentLangAtom } from "@/store/i18n"; +import { clipLangAtom } from "@/store/i18n"; import { currentVideoAtom, miniPlayerAtom } from "@/store/player"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useContext, useEffect, useLayoutEffect, useState } from "react"; @@ -23,9 +24,9 @@ import { useParams } from "react-router-dom"; export default function Watch() { const VideoPortalNode = useContext(VideoPortalContext); const { id } = useParams(); - const { value: currentLang } = useAtomValue(currentLangAtom); + const { value: clipLang } = useAtomValue(clipLangAtom); const { data, isSuccess } = useVideo( - { id: id!, lang: currentLang, c: "1" }, + { id: id!, lang: clipLang, c: "1" }, { enabled: !!id, refetchInterval: 1000 * 60 * 3, @@ -40,6 +41,10 @@ export default function Watch() { useLayoutEffect(() => { setMiniPlayer(false); + setCurrentVideo((curr) => ({ + ...curr, + url: `https://youtu.be/${id}`, + })); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -79,17 +84,19 @@ export default function Watch() {
-
+
{(data?.type === "stream" || data?.status === "live") && (
- +
setTLOpen(!tlOpen)} > {tlOpen ? "Close TL" : "Open TL"}
- + + +
)} diff --git a/packages/react/src/store/chat.ts b/packages/react/src/store/chat.ts new file mode 100644 index 000000000..c2c9c332c --- /dev/null +++ b/packages/react/src/store/chat.ts @@ -0,0 +1,50 @@ +import { atom } from "jotai"; +import { atomFamily, atomWithReducer } from "jotai/utils"; + +interface RoomState { + /** whether or not a room has finished loading all archived chat content*/ + completed: boolean; + /**whether or not a room is currently loading some content */ + loading: boolean; +} + +interface RoomInfo { + messages: ParsedMessage[]; + /** Tracks the loading state of history */ + state: RoomState; + /** playhead location */ + elapsed: number; + /** absolute second epoch of video player @ location, only available if video contains start_at */ + absolute?: number; +} + +export const roomsAtom = atomFamily((roomId) => + atom({ + messages: [], + state: { completed: false, loading: false }, + elapsed: 0, + absolute: 0, + }), +); + +export const videoToRoomAtom = atomFamily((videoId) => + atomWithReducer< + Set, + | { type: "add" | "del"; value: RoomIDString } + | { type: "clear"; value?: never } + >(new Set(), (prev, action) => { + switch (action?.type) { + case "add": + return prev.add(action.value); + case "del": + prev.delete(action.value); + break; + case "clear": + prev.clear(); + break; + default: + break; + } + return prev; + }), +); diff --git a/packages/react/src/store/i18n.ts b/packages/react/src/store/i18n.ts index 6d04a91ae..7c62a611e 100644 --- a/packages/react/src/store/i18n.ts +++ b/packages/react/src/store/i18n.ts @@ -4,16 +4,20 @@ import { atomWithStorage } from "jotai/utils"; import type { i18n } from "i18next"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { CLIPPER_LANGS } from "@/lib/consts"; export const localeAtom = atom({ lang: window.localStorage.getItem("i18nextLng") ?? navigator.language, dayjs: (...args: Parameters) => dayjs(...args), }); -export const currentLangAtom = atomWithStorage("lang", { - text: "English", - value: "en", -}); +export const clipLangAtom = atomWithStorage<(typeof CLIPPER_LANGS)[number]>( + "lang", + { + text: "English", + value: "en", + }, +); export const tFunctionAtom = atom(undefined); diff --git a/packages/react/src/store/player.ts b/packages/react/src/store/player.ts index cee93acac..88abfed52 100644 --- a/packages/react/src/store/player.ts +++ b/packages/react/src/store/player.ts @@ -1,8 +1,8 @@ import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; -interface CurrentVideo extends VideoRef { - url: string; +interface CurrentVideo extends Partial { + url?: string; } export const currentVideoAtom = atomWithStorage( diff --git a/packages/react/src/store/socket.ts b/packages/react/src/store/socket.ts new file mode 100644 index 000000000..6ce8b329d --- /dev/null +++ b/packages/react/src/store/socket.ts @@ -0,0 +1,25 @@ +import { atom } from "jotai"; +import { atomFamily, atomWithReducer } from "jotai/utils"; + +export const subscribedRoomsAtom = atomWithReducer< + Set, + { type: "add" | "del"; value: string } | { type: "clear"; value?: never } +>(new Set(), (prev, action) => { + switch (action?.type) { + case "add": + return prev.add(action.value); + case "del": + prev.delete(action.value); + break; + case "clear": + prev.clear(); + break; + default: + break; + } + return prev; +}); + +export const roomReferenceCounterAtom = atomFamily((roomId) => atom(0)); + +export const videoStateAtom = atom(new Map()); diff --git a/packages/react/src/store/tldex.ts b/packages/react/src/store/tldex.ts new file mode 100644 index 000000000..1a168170d --- /dev/null +++ b/packages/react/src/store/tldex.ts @@ -0,0 +1,43 @@ +import { TLLanguageCode } from "@/lib/consts"; +import { atom } from "jotai"; +import { clipLangAtom } from "./i18n"; + +export interface TLDexStoreState { + // whether live TL sticks to the bottom or not + liveTlStickBottom: boolean; + // language of dexTL + liveTlLang: TLLanguageCode; + // font size + liveTlFontSize: number; + // whether to show verified. + liveTlShowVerified: boolean; // show verified messages + // whether to show moderators + liveTlShowModerator: boolean; // show moderator messages + // whether to show vtubers + liveTlShowVtuber: boolean; // show vtuber messages + // whether to show local time + liveTlShowLocalTime: boolean; // show client local time + // window size + liveTlWindowSize: number; // Default size, otherwise percentage height + // subtitle on/off + liveTlShowSubtitle: boolean; // Show subtitles on videos + // hide spoilers or not? + liveTlHideSpoiler: boolean; // Hide message past current video time + + // blocked liveTL users. + liveTlBlocked: string[]; +} + +export const tldexStateAtom = atom((get) => ({ + liveTlStickBottom: false, + liveTlLang: "en" ?? get(clipLangAtom).value, + liveTlFontSize: 13, + liveTlShowVerified: true, // show verified messages + liveTlShowModerator: true, // show moderator messages + liveTlShowVtuber: true, // show vtuber messages + liveTlShowLocalTime: false, // show client local time + liveTlWindowSize: 0.3, // Default size, otherwise percentage height + liveTlShowSubtitle: true, // Show subtitles on videos + liveTlHideSpoiler: false, // Hide message past current video time + liveTlBlocked: [], +})); diff --git a/packages/react/src/types/lang.ts b/packages/react/src/types/lang.ts deleted file mode 100644 index 2d3557055..000000000 --- a/packages/react/src/types/lang.ts +++ /dev/null @@ -1,4 +0,0 @@ -interface Lang { - text: string; - value: string; -} diff --git a/packages/react/src/types/socket.d.ts b/packages/react/src/types/socket.d.ts new file mode 100644 index 000000000..94976a1a4 --- /dev/null +++ b/packages/react/src/types/socket.d.ts @@ -0,0 +1,53 @@ +interface TLDexMessage { + /** timestamp of message being sent or created. */ + timestamp: number | string; + /** a more accurate timestamp of seconds into video (may be not available) */ + video_offset: number; + /** message content */ + message: string; + name: string; // name of creator + channel_id?: string; + // breakpoint?: boolean; // breakpoints are used to add styling separation + // receivedAt?: number; + // I don't know why ReceivedAt was even in the definition in the first place, b/c nothing in the API or Holodex v2 codebase WRITEs it. + + /** duration of the translation (in milliseconds) */ + duration?: number; + + // TL Dex Live Message items: + type?: string; + is_tl?: boolean; + is_owner?: boolean; + is_vtuber?: boolean; + is_moderator?: boolean; + is_verified?: boolean; + + // rendering consideration? + key?: string; + // ID is a Database Unique Key. + id?: string; +} + +interface ParsedMessage extends TLDexMessage { + // Parsing converts timestamp to number. + timestamp: number; + /** parsed message if the message contains a link */ + parsed: string; + key: string; + + is_current?: boolean; + /** + * optionally provided video ID + */ + video_id?: string; + // relativeMs?: number; +} + +type VideoUpdatePayload = Pick< + Video, + "live_viewers" | "status" | "start_actual" +> & { type: "update" }; + +type TldexPayload = VideoUpdatePayload | TLDexMessage; + +type RoomIDString = `${string}/${import("../lib/consts").TLLanguageCode}`;