@@ -2,9 +2,12 @@ import { useSocket } from "@/hooks/useSocket";
2
2
import { formatDuration } from "@/lib/time" ;
3
3
import { cn , getChannelPhoto } from "@/lib/utils" ;
4
4
import { Badge } from "@/shadcn/ui/badge" ;
5
- import { playerRefAtom , videoStatusAtomFamily } from "@/store/player" ;
5
+ import {
6
+ videoPlayerRefAtomFamily ,
7
+ videoStatusAtomFamily ,
8
+ } from "@/store/player" ;
6
9
import { tldexBlockedAtom , tldexSettingsAtom } from "@/store/tldex" ;
7
- import { useAtom , useAtomValue } from "jotai" ;
10
+ import { getDefaultStore , useAtom , useAtomValue } from "jotai" ;
8
11
import {
9
12
DetailedHTMLProps ,
10
13
HTMLAttributes ,
@@ -15,16 +18,27 @@ import {
15
18
} from "react" ;
16
19
import { Virtuoso , VirtuosoHandle } from "react-virtuoso" ;
17
20
import "./tlchat.css" ;
21
+ import React from "react" ;
22
+ import ReactPlayer from "react-player" ;
18
23
19
24
interface TLChatProps {
20
25
videoId : string ;
21
26
}
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
+ */
23
37
function useTimestampIndex ( messages ?: ParsedMessage [ ] ) {
24
38
// Build and memoize the timestamp index
25
39
return useMemo ( ( ) => {
26
40
const findIndexForTimestamp = ( targetTimestamp : number ) => {
27
- if ( ! messages ) return 0 ;
41
+ if ( ! messages ) return undefined ;
28
42
29
43
let left = 0 ;
30
44
let right = messages . length - 1 ;
@@ -58,6 +72,35 @@ function useTimestampIndex(messages?: ParsedMessage[]) {
58
72
} , [ messages ] ) ;
59
73
}
60
74
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
+
61
104
export function TLChat ( { videoId } : TLChatProps ) {
62
105
const tldexState = useAtomValue ( tldexSettingsAtom ) ;
63
106
const roomID = useMemo (
@@ -76,23 +119,24 @@ export function TLChat({ videoId }: TLChatProps) {
76
119
( 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
77
120
} ) ) ;
78
121
} , [ chatDB . messages ] ) ;
122
+ const playerRef = useAtomValue ( videoPlayerRefAtomFamily ( videoId ) ) ;
79
123
80
124
// inverse index of timestamp to index of message
81
125
const findIndexForTimestamp = useTimestampIndex ( processedMessages ) ;
82
126
83
- // scroll to the video:
84
- const videoStatusAtom = videoStatusAtomFamily ( videoId ) ;
85
- const videoStatus = useAtomValue ( videoStatusAtom ) ;
86
-
87
127
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 ] ) ;
96
140
97
141
return (
98
142
< Virtuoso
@@ -106,7 +150,7 @@ export function TLChat({ videoId }: TLChatProps) {
106
150
startReached = { ( ) => chatDB . loadMessages ( { partial : 30 } ) }
107
151
data = { processedMessages }
108
152
itemContent = { ( _ , { key, ...message } ) => (
109
- < TLChatMessage { ...message } key = { key } />
153
+ < TLChatMessage { ...message } key = { key } player = { playerRef } />
110
154
) }
111
155
/>
112
156
) ;
@@ -128,20 +172,33 @@ function TLChatMessage({
128
172
parsed,
129
173
name,
130
174
video_offset,
175
+ duration,
131
176
is_owner,
132
177
is_verified,
133
178
is_vtuber,
134
179
is_moderator,
135
180
channel_id,
136
181
showHeader,
137
- } : ParsedMessage & { showHeader ?: boolean } ) {
138
- const playerRef = useAtomValue ( playerRefAtom ) ;
182
+ video_id,
183
+ player,
184
+ } : ParsedMessage & { showHeader ?: boolean ; player : ReactPlayer | null } ) {
139
185
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 ) ;
140
191
if ( blocked . includes ( name ) ) return null ;
141
192
return (
142
193
< 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
+ } }
145
202
>
146
203
{ showHeader && (
147
204
< div
@@ -180,7 +237,11 @@ function TLChatMessage({
180
237
</ div >
181
238
< span className = "line-clamp-1 whitespace-nowrap text-sm" >
182
239
{ name }
183
- { is_verified && < span className = "ml-2" > ✓</ span > }
240
+ { is_verified && (
241
+ < span className = "ml-2" title = "verified" >
242
+ ✓
243
+ </ span >
244
+ ) }
184
245
</ span >
185
246
</ div >
186
247
</ div >
0 commit comments