Skip to content

Commit 90114b1

Browse files
committed
highlight when message is active
1 parent 8070dbf commit 90114b1

File tree

3 files changed

+87
-24
lines changed

3 files changed

+87
-24
lines changed

packages/react/src/components/chat/YTChat.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface YTChatProps {
1313
const chatIframeRefs = new Map<string, HTMLIFrameElement>();
1414

1515
// Subscribe to video status updates outside of React
16-
export function subscribeToVideoProgress(videoId: string) {
16+
function subscribeToVideoProgress(videoId: string) {
1717
const videoStatusAtom = videoStatusAtomFamily(videoId);
1818
const store = getDefaultStore();
1919
return store.sub(videoStatusAtom, () => {

packages/react/src/components/tldex/TLChat.tsx

+83-22
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import { useSocket } from "@/hooks/useSocket";
22
import { formatDuration } from "@/lib/time";
33
import { cn, getChannelPhoto } from "@/lib/utils";
44
import { Badge } from "@/shadcn/ui/badge";
5-
import { playerRefAtom, videoStatusAtomFamily } from "@/store/player";
5+
import {
6+
videoPlayerRefAtomFamily,
7+
videoStatusAtomFamily,
8+
} from "@/store/player";
69
import { tldexBlockedAtom, tldexSettingsAtom } from "@/store/tldex";
7-
import { useAtom, useAtomValue } from "jotai";
10+
import { getDefaultStore, useAtom, useAtomValue } from "jotai";
811
import {
912
DetailedHTMLProps,
1013
HTMLAttributes,
@@ -15,16 +18,27 @@ import {
1518
} from "react";
1619
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
1720
import "./tlchat.css";
21+
import React from "react";
22+
import ReactPlayer from "react-player";
1823

1924
interface TLChatProps {
2025
videoId: string;
2126
}
22-
// Custom hook for timestamp indexing
27+
/**
28+
* Returns a memoized function that takes a target timestamp and returns the
29+
* index of the closest message in the given array of messages. If the array is
30+
* empty, returns 0. If the target timestamp is before the first message, returns
31+
* 0. If the target timestamp is after the last message, returns the last index.
32+
* Otherwise, performs a binary search to find the closest match.
33+
* @param messages - The array of messages to search
34+
* @returns A function that takes a target timestamp and returns the index of the
35+
* closest message
36+
*/
2337
function useTimestampIndex(messages?: ParsedMessage[]) {
2438
// Build and memoize the timestamp index
2539
return useMemo(() => {
2640
const findIndexForTimestamp = (targetTimestamp: number) => {
27-
if (!messages) return 0;
41+
if (!messages) return undefined;
2842

2943
let left = 0;
3044
let right = messages.length - 1;
@@ -58,6 +72,35 @@ function useTimestampIndex(messages?: ParsedMessage[]) {
5872
}, [messages]);
5973
}
6074

75+
/**
76+
* Subscribe to video status updates outside of React and scroll the virtuoso
77+
* component to the index that is closest to the video's current progress.
78+
*
79+
* @param videoId The video ID to subscribe to.
80+
* @param indexFinder A function that given a timestamp, returns the index of the
81+
* message that is closest to it.
82+
* @param virtuosoRef A ref to the Virtuoso component.
83+
* @returns A subscription that will be called when the video status changes.
84+
*/
85+
function scrollToVideoProgress(
86+
videoId: string,
87+
indexFinder: (targetTimestamp: number) => number | undefined,
88+
virtuosoRef: React.RefObject<VirtuosoHandle>,
89+
) {
90+
const videoStatusAtom = videoStatusAtomFamily(videoId);
91+
const store = getDefaultStore();
92+
return store.sub(videoStatusAtom, () => {
93+
const videoStatus = store.get(videoStatusAtom);
94+
if (virtuosoRef.current && videoStatus?.progress !== undefined) {
95+
const index = indexFinder(videoStatus.progress);
96+
virtuosoRef.current?.scrollToIndex({
97+
index: index ?? "LAST",
98+
align: "end",
99+
});
100+
}
101+
});
102+
}
103+
61104
export function TLChat({ videoId }: TLChatProps) {
62105
const tldexState = useAtomValue(tldexSettingsAtom);
63106
const roomID = useMemo(
@@ -76,23 +119,24 @@ export function TLChat({ videoId }: TLChatProps) {
76119
(i > 5 && arr[i - 5]?.name === msg.name), // This condition checks if the message 5 positions back in the array has the same name as the current message
77120
}));
78121
}, [chatDB.messages]);
122+
const playerRef = useAtomValue(videoPlayerRefAtomFamily(videoId));
79123

80124
// inverse index of timestamp to index of message
81125
const findIndexForTimestamp = useTimestampIndex(processedMessages);
82126

83-
// scroll to the video:
84-
const videoStatusAtom = videoStatusAtomFamily(videoId);
85-
const videoStatus = useAtomValue(videoStatusAtom);
86-
87127
useEffect(() => {
88-
if (videoStatus?.progress === undefined || videoStatus.status !== "playing")
89-
return;
90-
const index = findIndexForTimestamp(videoStatus.progress);
91-
virtuosoRef.current?.scrollToIndex({
92-
index: index || "LAST",
93-
align: "end",
94-
});
95-
}, [findIndexForTimestamp, videoStatus.progress, videoStatus.status]);
128+
// Set up subscription
129+
const unsubscribe = scrollToVideoProgress(
130+
videoId,
131+
findIndexForTimestamp,
132+
virtuosoRef,
133+
);
134+
135+
// Cleanup
136+
return () => {
137+
unsubscribe();
138+
};
139+
}, [videoId, findIndexForTimestamp]);
96140

97141
return (
98142
<Virtuoso
@@ -106,7 +150,7 @@ export function TLChat({ videoId }: TLChatProps) {
106150
startReached={() => chatDB.loadMessages({ partial: 30 })}
107151
data={processedMessages}
108152
itemContent={(_, { key, ...message }) => (
109-
<TLChatMessage {...message} key={key} />
153+
<TLChatMessage {...message} key={key} player={playerRef} />
110154
)}
111155
/>
112156
);
@@ -128,20 +172,33 @@ function TLChatMessage({
128172
parsed,
129173
name,
130174
video_offset,
175+
duration,
131176
is_owner,
132177
is_verified,
133178
is_vtuber,
134179
is_moderator,
135180
channel_id,
136181
showHeader,
137-
}: ParsedMessage & { showHeader?: boolean }) {
138-
const playerRef = useAtomValue(playerRefAtom);
182+
video_id,
183+
player,
184+
}: ParsedMessage & { showHeader?: boolean; player: ReactPlayer | null }) {
139185
const [blocked, setBlocked] = useAtom(tldexBlockedAtom);
186+
const { progress } = useAtomValue(videoStatusAtomFamily(video_id || ""));
187+
// highlighted:
188+
const highlighted =
189+
video_offset < progress &&
190+
progress < video_offset + (duration ? duration / 1000 : 5000);
140191
if (blocked.includes(name)) return null;
141192
return (
142193
<div
143-
className="flex flex-col p-1 px-2 hover:cursor-pointer hover:bg-base-4"
144-
onClick={() => playerRef?.seekTo(video_offset, "seconds")}
194+
className={cn(
195+
"flex flex-col p-1 px-2 hover:cursor-pointer hover:bg-base-4",
196+
highlighted && "bg-primary-4 hover:bg-primary-5",
197+
)}
198+
onClick={() => {
199+
player?.seekTo(video_offset, "seconds");
200+
player?.getInternalPlayer()?.playVideo();
201+
}}
145202
>
146203
{showHeader && (
147204
<div
@@ -180,7 +237,11 @@ function TLChatMessage({
180237
</div>
181238
<span className="line-clamp-1 whitespace-nowrap text-sm">
182239
{name}
183-
{is_verified && <span className="ml-2"></span>}
240+
{is_verified && (
241+
<span className="ml-2" title="verified">
242+
243+
</span>
244+
)}
184245
</span>
185246
</div>
186247
</div>

packages/react/vite.config.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ export default defineConfig({
107107
// plugins: [["@swc-jotai/debug-label", {}]],
108108
// devTarget: "esnext", // SWC only.
109109
}),
110-
UnoCSS({ presets: [presetIcons()] }),
110+
UnoCSS({
111+
presets: [presetIcons()],
112+
}),
111113
yaml() as unknown as PluginOption,
112114
dynamicImportVars({
113115
// for importing yml dynamically.

0 commit comments

Comments
 (0)