Spaces:
Running
Running
Commit
·
bde82a3
1
Parent(s):
664b6fe
add support for time seeks
Browse files
src/app/interface/comment-card/index.tsx
CHANGED
@@ -3,6 +3,7 @@ import { CommentInfo } from "@/types"
|
|
3 |
import { useEffect, useState } from "react"
|
4 |
import { DefaultAvatar } from "../default-avatar"
|
5 |
import { formatTimeAgo } from "@/lib/formatTimeAgo"
|
|
|
6 |
|
7 |
export function CommentCard({
|
8 |
comment,
|
@@ -34,7 +35,6 @@ export function CommentCard({
|
|
34 |
}
|
35 |
}
|
36 |
|
37 |
-
|
38 |
return (
|
39 |
<div className={cn(
|
40 |
`flex flex-col w-full`,
|
@@ -77,14 +77,26 @@ export function CommentCard({
|
|
77 |
<div className="text-xs font-medium text-zinc-100">@{comment?.userInfo?.userName}</div>
|
78 |
<div className="text-xs font-medium text-neutral-400">{formatTimeAgo(comment.updatedAt)}</div>
|
79 |
</div>
|
80 |
-
<
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
{isLongContent &&
|
89 |
<div className={cn(
|
90 |
`flex`,
|
|
|
3 |
import { useEffect, useState } from "react"
|
4 |
import { DefaultAvatar } from "../default-avatar"
|
5 |
import { formatTimeAgo } from "@/lib/formatTimeAgo"
|
6 |
+
import { CommentWithTimeSeeks } from "./time-seeker"
|
7 |
|
8 |
export function CommentCard({
|
9 |
comment,
|
|
|
35 |
}
|
36 |
}
|
37 |
|
|
|
38 |
return (
|
39 |
<div className={cn(
|
40 |
`flex flex-col w-full`,
|
|
|
77 |
<div className="text-xs font-medium text-zinc-100">@{comment?.userInfo?.userName}</div>
|
78 |
<div className="text-xs font-medium text-neutral-400">{formatTimeAgo(comment.updatedAt)}</div>
|
79 |
</div>
|
80 |
+
<CommentWithTimeSeeks
|
81 |
+
className={cn(
|
82 |
+
`text-sm font-normal`,
|
83 |
+
`shrink`,
|
84 |
+
`overflow-hidden break-words`,
|
85 |
+
isExpanded ? `` : `line-clamp-4`
|
86 |
+
)}
|
87 |
+
linkClassName="font-medium text-neutral-400 cursor-pointer hover:underline"
|
88 |
+
onSeek={(timeInSec) => {
|
89 |
+
try {
|
90 |
+
const videoElement: HTMLVideoElement | undefined = (document.getElementsByClassName("tuby-container")?.[0]?.children?.[0] as any)
|
91 |
+
if (videoElement) {
|
92 |
+
videoElement.currentTime = timeInSec
|
93 |
+
}
|
94 |
+
} catch (err) {
|
95 |
+
//
|
96 |
+
}
|
97 |
+
}}>{
|
98 |
+
comment.message
|
99 |
+
}</CommentWithTimeSeeks>
|
100 |
{isLongContent &&
|
101 |
<div className={cn(
|
102 |
`flex`,
|
src/app/interface/comment-card/time-seeker.tsx
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react"
|
2 |
+
|
3 |
+
interface CommentWithTimeSeeksProps {
|
4 |
+
onSeek: (timeInSec: number) => void;
|
5 |
+
children: string;
|
6 |
+
className?: string;
|
7 |
+
linkClassName?: string;
|
8 |
+
}
|
9 |
+
|
10 |
+
export const CommentWithTimeSeeks: React.FC<CommentWithTimeSeeksProps> = ({ onSeek, children, className, linkClassName }) => {
|
11 |
+
// This function converts a time string like "HH:MM:SS", "MM:SS" or "SS" to seconds.
|
12 |
+
const convertTimeToSeconds = (timeString: string): number => {
|
13 |
+
const units = timeString.split(":").map(unit => parseInt(unit, 10));
|
14 |
+
const seconds = units.reverse().reduce((acc, unit, index) => acc + unit * Math.pow(60, index), 0);
|
15 |
+
return seconds;
|
16 |
+
};
|
17 |
+
|
18 |
+
// This function parses the text and replaces time seeks with clickable spans.
|
19 |
+
const renderWithTimeSeek = (text: string) => {
|
20 |
+
const timeSeekPattern = /\b(\d{1,2}:)?\d{1,2}:\d{2}\b/g;
|
21 |
+
const nodes = [];
|
22 |
+
let lastIndex = 0;
|
23 |
+
|
24 |
+
text.replace(timeSeekPattern, (match, ...args) => {
|
25 |
+
const index = args[args.length - 2]; // The second to last argument is the index of the match
|
26 |
+
nodes.push(
|
27 |
+
<React.Fragment key={lastIndex}>
|
28 |
+
{text.slice(lastIndex, index)}{/* Text before the match */}
|
29 |
+
<span
|
30 |
+
className={linkClassName}
|
31 |
+
onClick={() => onSeek(convertTimeToSeconds(match))}
|
32 |
+
>
|
33 |
+
{match}
|
34 |
+
</span>
|
35 |
+
</React.Fragment>
|
36 |
+
);
|
37 |
+
lastIndex = index + match.length; // Update lastIndex to end of the current match
|
38 |
+
return match; // return value is unused but needed for `replace` to work
|
39 |
+
});
|
40 |
+
|
41 |
+
// Append the remaining text after the last match (if any)
|
42 |
+
if (lastIndex < text.length) {
|
43 |
+
nodes.push(<React.Fragment key={lastIndex}>{text.slice(lastIndex)}</React.Fragment>);
|
44 |
+
}
|
45 |
+
|
46 |
+
return nodes;
|
47 |
+
};
|
48 |
+
|
49 |
+
return <p className={className}>{renderWithTimeSeek(children)}</p>;
|
50 |
+
};
|
src/app/interface/video-player/index.tsx
CHANGED
@@ -5,17 +5,33 @@ import "react-tuby/css/main.css"
|
|
5 |
|
6 |
import { cn } from "@/lib/utils"
|
7 |
import { VideoInfo } from "@/types"
|
|
|
|
|
8 |
|
9 |
export function VideoPlayer({
|
10 |
video,
|
11 |
enableShortcuts = true,
|
12 |
-
className = ""
|
|
|
13 |
}: {
|
14 |
video?: VideoInfo
|
15 |
enableShortcuts?: boolean
|
16 |
className?: string
|
|
|
17 |
}) {
|
18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
// TODO: keep the same form factor?
|
20 |
if (!video) { return null }
|
21 |
|
@@ -33,6 +49,8 @@ export function VideoPlayer({
|
|
33 |
)}>
|
34 |
<Player
|
35 |
|
|
|
|
|
36 |
src={[
|
37 |
{
|
38 |
quality: "Full HD",
|
|
|
5 |
|
6 |
import { cn } from "@/lib/utils"
|
7 |
import { VideoInfo } from "@/types"
|
8 |
+
import { MutableRefObject, useEffect, useRef } from "react"
|
9 |
+
import { isValidNumber } from "@/app/server/actions/utils/isValidNumber"
|
10 |
|
11 |
export function VideoPlayer({
|
12 |
video,
|
13 |
enableShortcuts = true,
|
14 |
+
className = "",
|
15 |
+
// currentTime,
|
16 |
}: {
|
17 |
video?: VideoInfo
|
18 |
enableShortcuts?: boolean
|
19 |
className?: string
|
20 |
+
// currentTime?: number
|
21 |
}) {
|
22 |
|
23 |
+
/*
|
24 |
+
const ref = useRef(null)
|
25 |
+
|
26 |
+
useEffect(() => {
|
27 |
+
if (!ref.current) { return }
|
28 |
+
if (!isValidNumber(currentTime)) { return }
|
29 |
+
|
30 |
+
(ref.current as any).currentTime = currentTime
|
31 |
+
// $(".tuby-container video").currentTime = 2
|
32 |
+
}, [currentTime])
|
33 |
+
*/
|
34 |
+
|
35 |
// TODO: keep the same form factor?
|
36 |
if (!video) { return null }
|
37 |
|
|
|
49 |
)}>
|
50 |
<Player
|
51 |
|
52 |
+
// playerRef={ref}
|
53 |
+
|
54 |
src={[
|
55 |
{
|
56 |
quality: "Full HD",
|
src/app/views/public-video-view/index.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
"use client"
|
2 |
|
3 |
-
import { useEffect, useState, useTransition } from "react"
|
4 |
import { RiCheckboxCircleFill } from "react-icons/ri"
|
5 |
import { PiShareFatLight } from "react-icons/pi"
|
6 |
import CopyToClipboard from "react-copy-to-clipboard"
|
@@ -37,9 +37,14 @@ export function PublicVideoView() {
|
|
37 |
const [isCommenting, setCommenting] = useState(false)
|
38 |
const [isFocusedOnInput, setFocusedOnInput] = useState(false)
|
39 |
|
|
|
|
|
|
|
|
|
|
|
40 |
const currentUser = useCurrentUser()
|
41 |
|
42 |
-
|
43 |
|
44 |
useEffect(() => {
|
45 |
setUserThumbnail(currentUser?.thumbnail || "")
|
@@ -52,6 +57,7 @@ export function PublicVideoView() {
|
|
52 |
}
|
53 |
}
|
54 |
|
|
|
55 |
const video = useStore(s => s.publicVideo)
|
56 |
|
57 |
const videoId = `${video?.id || ""}`
|
@@ -166,6 +172,10 @@ export function PublicVideoView() {
|
|
166 |
<VideoPlayer
|
167 |
video={video}
|
168 |
enableShortcuts={!isFocusedOnInput}
|
|
|
|
|
|
|
|
|
169 |
className="mb-4"
|
170 |
/>
|
171 |
|
|
|
1 |
"use client"
|
2 |
|
3 |
+
import { useEffect, useRef, useState, useTransition } from "react"
|
4 |
import { RiCheckboxCircleFill } from "react-icons/ri"
|
5 |
import { PiShareFatLight } from "react-icons/pi"
|
6 |
import CopyToClipboard from "react-copy-to-clipboard"
|
|
|
37 |
const [isCommenting, setCommenting] = useState(false)
|
38 |
const [isFocusedOnInput, setFocusedOnInput] = useState(false)
|
39 |
|
40 |
+
// current time in the video
|
41 |
+
// note: this is used to *set* the current time, not to read it
|
42 |
+
// EDIT: you know what, let's do this the dirty way for now
|
43 |
+
// const [desiredCurrentTime, setDesiredCurrentTime] = useState()
|
44 |
+
|
45 |
const currentUser = useCurrentUser()
|
46 |
|
47 |
+
const [userThumbnail, setUserThumbnail] = useState("")
|
48 |
|
49 |
useEffect(() => {
|
50 |
setUserThumbnail(currentUser?.thumbnail || "")
|
|
|
57 |
}
|
58 |
}
|
59 |
|
60 |
+
|
61 |
const video = useStore(s => s.publicVideo)
|
62 |
|
63 |
const videoId = `${video?.id || ""}`
|
|
|
172 |
<VideoPlayer
|
173 |
video={video}
|
174 |
enableShortcuts={!isFocusedOnInput}
|
175 |
+
|
176 |
+
// that could be, but let's do it the dirty way for now
|
177 |
+
// currentTime={desiredCurrentTime}
|
178 |
+
|
179 |
className="mb-4"
|
180 |
/>
|
181 |
|