diff --git a/frontend/src/components/CommentBox.tsx b/frontend/src/components/CommentBox.tsx
index c80c70bb..f7f71379 100644
--- a/frontend/src/components/CommentBox.tsx
+++ b/frontend/src/components/CommentBox.tsx
@@ -37,9 +37,10 @@ export type CommentsType = {
type CommentBoxProps = {
comment: CommentsType;
+ onTimestampClick?: (seconds: number) => void;
};
-const CommentBox = ({ comment }: CommentBoxProps) => {
+const CommentBox = ({ comment, onTimestampClick }: CommentBoxProps) => {
const [showSubComments, setShowSubComments] = useState(false);
const hasSubComments =
@@ -51,7 +52,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => {
{comment.comment_author}
@@ -95,7 +96,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => {
comment.comment_replies?.map(comment => {
return (
-
+
);
})}
diff --git a/frontend/src/components/Linkify.tsx b/frontend/src/components/Linkify.tsx
index a9d1d839..57dd92cb 100644
--- a/frontend/src/components/Linkify.tsx
+++ b/frontend/src/components/Linkify.tsx
@@ -3,17 +3,61 @@ import DOMPurify from 'dompurify';
type LinkifyProps = {
children: string;
ignoreLineBreak?: boolean;
+ onTimestampClick?: (seconds: number) => void;
};
// source: https://www.js-craft.io/blog/react-detect-url-text-convert-link/
-const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => {
+const Linkify = ({ children, ignoreLineBreak = false, onTimestampClick }: LinkifyProps) => {
const isUrl = (word: string) => {
const urlPattern = /(https?:\/\/[^\s]+)/g;
return word.match(urlPattern);
};
+ const isTimestamp = (word: string) => {
+ const timestampPattern = /^(\d{1,}:)?(\d{1,2}):(\d{1,2})$/;
+ return word.match(timestampPattern);
+ };
+
+ const parseTimestamp = (timestamp: string): number => {
+ const parts = timestamp.split(':').map(part => parseInt(part, 10));
+
+ if (parts.length === 2) {
+ // MM:SS format
+ const [minutes, seconds] = parts;
+ return minutes * 60 + seconds;
+ }
+
+ if (parts.length === 3) {
+ // HH:MM:SS format
+ const [hours, minutes, seconds] = parts;
+ return hours * 3600 + minutes * 60 + seconds;
+ }
+
+ return 0;
+ };
+
const addMarkup = (word: string) => {
- return isUrl(word) ? `
${word}` : word;
+ if (isUrl(word)) {
+ return `
${word}`;
+ }
+
+ if (isTimestamp(word) && onTimestampClick) {
+ const seconds = parseTimestamp(word);
+ return `
${word}`;
+ }
+
+ return word;
+ };
+
+ const handleClick = (event: React.MouseEvent
) => {
+ const target = event.target as HTMLElement;
+ if (target.classList.contains('timestamp-link') && onTimestampClick) {
+ const timestamp = target.getAttribute('data-timestamp');
+ if (timestamp) {
+ const seconds = parseInt(timestamp, 10);
+ onTimestampClick(seconds);
+ }
+ }
};
let workingText = children;
@@ -26,9 +70,11 @@ const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => {
const formatedWords = words.map(w => addMarkup(w));
- const html = DOMPurify.sanitize(formatedWords.join(' '));
+ const html = DOMPurify.sanitize(formatedWords.join(' '), {
+ ADD_ATTR: ['target'],
+ });
- return ;
+ return ;
};
export default Linkify;
diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx
index de8fc4bb..d6c66c15 100644
--- a/frontend/src/components/VideoPlayer.tsx
+++ b/frontend/src/components/VideoPlayer.tsx
@@ -111,6 +111,8 @@ type VideoPlayerProps = {
autoplay?: boolean;
onWatchStateChanged?: (status: boolean) => void;
onVideoEnd?: () => void;
+ seekToTimestamp?: number;
+ setSeekToTimestamp?: (timestamp: number | undefined) => void;
};
const VideoPlayer = ({
@@ -120,9 +122,30 @@ const VideoPlayer = ({
autoplay = false,
onWatchStateChanged,
onVideoEnd,
+ seekToTimestamp,
+ setSeekToTimestamp,
}: VideoPlayerProps) => {
const videoRef = useRef(null);
+ useEffect(() => {
+ if (seekToTimestamp === undefined || !videoRef.current) {
+ return;
+ }
+
+ const videoDuration = videoRef.current.duration;
+ if (isNaN(videoDuration) || seekToTimestamp > videoDuration) {
+ return;
+ }
+
+ videoRef.current.currentTime = seekToTimestamp;
+
+ if (videoRef.current.paused) {
+ videoRef.current.play();
+ }
+ if (setSeekToTimestamp) setSeekToTimestamp(undefined);
+ window.scroll(0, 0);
+ }, [seekToTimestamp]);
+
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
diff --git a/frontend/src/pages/Video.tsx b/frontend/src/pages/Video.tsx
index 68929f38..93c211b0 100644
--- a/frontend/src/pages/Video.tsx
+++ b/frontend/src/pages/Video.tsx
@@ -109,6 +109,7 @@ const Video = () => {
const { userConfig } = useUserConfigStore();
const [videoEnded, setVideoEnded] = useState(false);
+ const [seekToTimestamp, setSeekToTimestamp] = useState();
const [playlistAutoplay, setPlaylistAutoplay] = useState(
localStorage.getItem('playlistAutoplay') === 'true',
);
@@ -135,6 +136,10 @@ const Video = () => {
const { data: customPlaylistsResponseData } = customPlaylistsResponse ?? {};
const { data: commentsResponseData } = commentsResponse ?? {};
+ const handleTimestampClick = (seconds: number) => {
+ setSeekToTimestamp(seconds);
+ };
+
useEffect(() => {
(async () => {
if (refreshVideoList || videoId !== videoResponseData?.youtube_id) {
@@ -224,6 +229,8 @@ const Video = () => {
video={video}
sponsorBlock={sponsorBlock}
autoplay={playlistAutoplay}
+ seekToTimestamp={seekToTimestamp}
+ setSeekToTimestamp={setSeekToTimestamp}
onWatchStateChanged={() => {
setRefreshVideoList(true);
}}
@@ -492,7 +499,7 @@ const Video = () => {
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
className="description-text"
>
- {video.description}
+ {video.description}