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:
parent
fa7643e903
commit
624a5f9bd4
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue