Add timestamp seeking (#989)

* Add timestamp seeking

* Remove unnecessary else

* handle setSeekToTimestamp reset in player

---------

Co-authored-by: Simon <simobilleter@gmail.com>
This commit is contained in:
Loris Leitner 2025-07-11 12:08:43 +02:00 committed by GitHub
parent fa7643e903
commit 624a5f9bd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 92 additions and 9 deletions

View File

@ -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}
</h3>
<p>
<Linkify>{comment.comment_text}</Linkify>
<Linkify onTimestampClick={onTimestampClick}>{comment.comment_text}</Linkify>
</p>
<div className="comment-meta">
@ -95,7 +96,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => {
comment.comment_replies?.map(comment => {
return (
<Fragment key={comment.comment_id}>
<CommentBox comment={comment} />
<CommentBox comment={comment} onTimestampClick={onTimestampClick} />
</Fragment>
);
})}

View File

@ -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) ? `<a href="${word}">${word}</a>` : word;
if (isUrl(word)) {
return `<a href="${word}" rel="noopener noreferrer" target="_blank">${word}</a>`;
}
if (isTimestamp(word) && onTimestampClick) {
const seconds = parseTimestamp(word);
return `<span class="timestamp-link" data-timestamp="${seconds}">${word}</span>`;
}
return word;
};
const handleClick = (event: React.MouseEvent<HTMLSpanElement>) => {
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 <span dangerouslySetInnerHTML={{ __html: html }} />;
return <span dangerouslySetInnerHTML={{ __html: html }} onClick={handleClick} />;
};
export default Linkify;

View File

@ -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<HTMLVideoElement | null>(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');

View File

@ -109,6 +109,7 @@ const Video = () => {
const { userConfig } = useUserConfigStore();
const [videoEnded, setVideoEnded] = useState(false);
const [seekToTimestamp, setSeekToTimestamp] = useState<number>();
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"
>
<Linkify>{video.description}</Linkify>
<Linkify onTimestampClick={handleTimestampClick}>{video.description}</Linkify>
</p>
<Button
@ -605,7 +612,7 @@ const Video = () => {
{comments?.map(comment => {
return (
<Fragment key={comment.comment_id}>
<CommentBox comment={comment} />
<CommentBox comment={comment} onTimestampClick={handleTimestampClick} />
</Fragment>
);
})}

View File

@ -989,6 +989,12 @@ video:-webkit-full-screen {
filter: var(--img-filter-error);
}
.timestamp-link {
color: var(--accent-font-light);
cursor: pointer;
font-weight: bold;
}
/* multi search page */
.multi-search-box {
padding-right: 20px;