wrolpi/app/src/components/VideoPlayer.js
Roland 106596f934
app: video comments and description distinguish pending from empty
useVideoExtras initialised both values to null and the page treated
null the same as "none", so every video open (and every prev/next)
showed "No comments have been downloaded. Refresh the video?" with a
live Refresh button until the comments arrived.  The Description tab
showed "No description available." the same way.  A failed fetch was
also rendered as "none", and the previous video's data lingered until
the next video's responses arrived.

Invariants now enforced and tested:
* null = pending, undefined = fetch failed, [] / '' = loaded and empty.
  The API serialises a missing info_json field as null, so the hook
  normalises that to empty; consumers never see null for "none".
* Switching video resets both to pending, and a slow response for the
  previous video is dropped.
* The page renders a placeholder while pending, an error message on
  failure, and offers Refresh only once the server confirmed there are
  no comments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:13:44 -06:00

801 lines
32 KiB
JavaScript

import React, {useEffect, useState} from 'react';
import {useHotkeys} from 'react-hotkeys-hook';
import {deleteFileGroups, downloadVideoMetadata, tagFileGroup, untagFileGroup} from "../api";
import {Link, useNavigate, useParams} from "react-router";
import _ from "lodash";
import {
APIButton,
BackButton,
DirectoryLink,
encodeMediaPath,
ErrorMessage,
humanFileSize,
humanNumber,
isoDatetimeToAgoPopup,
MultilineText,
PageContainer,
PreviewPath,
useMediaSession,
useTitle
} from "./Common";
import {VideoPlaceholder} from "./Placeholder";
import {useChannel, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
import {Button, Grid, Header, Icon, Label, Loading, Panel, Placeholder, Tabs, TextInput} from "./ui";
import {VideoCard} from "./Videos";
import {TagsSelector} from "../Tags";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {VideoEditMenu} from "./VideoEditMenu";
import {IconPlayerPause, IconPlayerPlay} from "@tabler/icons-react";
const MEDIA_PATH = '/media';
// YouTube auto-generated captions (downloaded via yt-dlp) stamp every cue with `align:start position:0%`,
// which pins the text to the bottom-left of the player and makes it hard to read. Detect that signature and
// reset those cues to the browser's centered default. This mutates only the in-memory VTTCue objects the
// browser parsed; the .vtt file on disk is never modified. Cues that were deliberately positioned (anything
// other than the auto-caption signature) are left untouched. YouTube auto-captions never set `line`, so we
// require `line === 'auto'` too; that leaves a deliberately placed cue (e.g. `line:0` for top-edge) alone even
// if it happens to also be `align:start position:0%`.
export function centerAutoCaptions(track) {
if (!track || !track.cues) {
return 0;
}
let changed = 0;
for (const cue of track.cues) {
if (cue.align === 'start' && cue.position === 0 && cue.line === 'auto') {
cue.align = 'center';
cue.position = 'auto';
changed++;
}
}
return changed;
}
function handleCaptionTrackLoad(event) {
const track = event.target.track;
if (!track) {
return;
}
if (track.cues && track.cues.length > 0) {
centerAutoCaptions(track);
} else {
// A non-default track has mode 'disabled' at load time, so its cues are not parsed yet. Normalize
// them once the browser populates the cue list (when the user selects the track and playback advances
// into a cue). Only detach after cues actually exist, in case a cuechange fires before they load.
const onCueChange = () => {
if (track.cues && track.cues.length > 0) {
centerAutoCaptions(track);
track.removeEventListener('cuechange', onCueChange);
}
};
track.addEventListener('cuechange', onCueChange);
}
}
export function CaptionTrack({src, ...props}) {
// Center YouTube auto-generated captions once the browser parses the cues (see centerAutoCaptions).
props = {onLoad: handleCaptionTrackLoad, ...props};
if (src.endsWith('.de.vtt')) {
return <track kind="captions" label="German" src={src} srcLang="de" {...props}/>
} else if (src.endsWith('.es.vtt')) {
return <track kind="captions" label="Spanish" src={src} srcLang="es" {...props}/>
} else if (src.endsWith('.fr.vtt')) {
return <track kind="captions" label="French" src={src} srcLang="fr" {...props}/>
} else if (src.endsWith('.id.vtt')) {
return <track kind="captions" label="Indonesian" src={src} srcLang="id" {...props}/>
} else if (src.endsWith('.it.vtt')) {
return <track kind="captions" label="Italian" src={src} srcLang="it" {...props}/>
} else if (src.endsWith('.ja.vtt')) {
return <track kind="captions" label="Japanese" src={src} srcLang="ja" {...props}/>
} else if (src.endsWith('.pl.vtt')) {
return <track kind="captions" label="Polish" src={src} srcLang="pl" {...props}/>
} else if (src.endsWith('.pt.vtt')) {
return <track kind="captions" label="Portuguese" src={src} srcLang="pt" {...props}/>
} else if (src.endsWith('.ro.vtt')) {
return <track kind="captions" label="Romanian" src={src} srcLang="ro" {...props}/>
} else if (src.endsWith('.ru.vtt')) {
return <track kind="captions" label="Russian" src={src} srcLang="ru" {...props}/>
} else if (src.endsWith('.th.vtt')) {
return <track kind="captions" label="Thai" src={src} srcLang="th" {...props}/>
}
return <track kind="captions" label="English" src={src} srcLang="en" default {...props}/>
}
const NoComments = ({video}) => {
const videoUrl = video.url;
const handleRefresh = async () => {
await downloadVideoMetadata(videoUrl);
};
if (videoUrl) {
return <React.Fragment>
<p>No comments have been been downloaded. Refresh the video?</p>
<APIButton
role='primary'
size='large'
onClick={handleRefresh}
>Refresh</APIButton>
</React.Fragment>
} else {
return <React.Fragment>
<p>No comment have been downloaded. WROLPi does not know the URL of this video, so it cannot download
the comments.</p>
<big>Try finding and downloading the video again.</big>
</React.Fragment>
}
}
// The token library has no comment-thread component -- this is a nested
// comment thread, not a reusable widget, so it is built here from plain elements and tokens.
const VideoComment = ({comment, children}) => {
const {is_favorited, like_count, author, timestamp, text, author_is_uploader} = comment;
// Author comments and favorited comments are important.
let specialIcon = null;
if (author_is_uploader) {
specialIcon = <Icon name='star' label='Uploader' style={{color: 'var(--green)'}}/>;
} else if (is_favorited) {
specialIcon = <Icon name='heart' label='Favorited' style={{color: 'var(--red)'}}/>;
}
return <div style={{marginBottom: '1.2em'}}>
<div style={{fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.3em'}}>
{specialIcon}<span>{author}</span>
</div>
<div style={{display: 'flex', gap: '1em', fontSize: '0.85em', color: 'var(--muted)'}}>
<div>{isoDatetimeToAgoPopup(timestamp * 1000)}</div>
{like_count ? <div>
<Icon name='thumbs up' size={14}/> {humanNumber(comment['like_count'])}
</div> : null}
</div>
<div>
<MultilineText text={text} style={{marginLeft: '0.5em'}}/>
</div>
{children && !_.isEmpty(children) &&
<div style={{
marginLeft: '1.5em',
marginTop: '0.7em',
paddingLeft: '1em',
borderLeft: '1px solid var(--border)',
}}>
{children.map((i, key) => <VideoComment key={key} comment={i}/>)}
</div>
}
</div>
}
// `comments` follows useVideoExtras: null = pending, undefined = fetch failed, [] = none downloaded.
// Only the last of those may offer the Refresh button.
export const Comments = ({comments, video}) => {
if (comments === null) {
return <Placeholder lines={3}/>
} else if (comments === undefined) {
return <ErrorMessage>Could not fetch the comments.</ErrorMessage>
} else if (_.isEmpty(comments)) {
return <NoComments video={video}/>
}
comments.sort((a, b) => {
// Treat undefined like_count as 0
const likeA = a.like_count ?? 0;
const likeB = b.like_count ?? 0;
// For descending order
return likeB - likeA;
});
const rootComments = comments.filter(i => i['parent'] === 'root');
// Comments are in an array, convert it to a dict, grouped by their parent.
const groupedByParent = {};
comments.forEach(obj => {
if (!groupedByParent[obj.parent]) {
groupedByParent[obj.parent] = [];
}
groupedByParent[obj.parent].push(obj);
})
// Sort replies to each parent by the time they were created.
comments.forEach(obj => {
const children = groupedByParent[obj.parent];
groupedByParent[obj.parent] = _.sortBy(children, ['timestamp']);
})
return <div>
{rootComments.map((i, key) => <VideoComment key={key} comment={i} children={groupedByParent[i.id]}/>)}
</div>
}
// Generate a video page URL for a file object.
function getVideoLink(file) {
if (!file || !file.video) return null;
return `/videos/${file.id}`;
}
function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
const navigate = useNavigate();
const videoRef = React.useRef();
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
const [activeTab, setActiveTab] = useState('comments');
useTitle(videoFile ? videoFile.title ? videoFile.title : videoFile.name : null);
let video;
if (videoFile) {
video = videoFile.video;
}
// Seeks to the `seconds` on video player.
const setVideoTime = (seconds) => {
videoRef.current.currentTime = seconds;
}
// Get the Video's channel, fallback to the URL's channel id.
const {channelId} = useParams();
const {channel} = useChannel(video && video.channel_id ? video.channel_id : channelId);
const {comments, description: videoDescription, fetchDescription} = useVideoExtras(videoFile?.id);
const {captions, captionsLoading, fetchCaptions} = useVideoCaptions(videoFile?.id);
const mediaTitle = videoFile ? (videoFile.title || videoFile.stem || videoFile.name) : null;
const mediaArtworkUrl = video && video.poster_path ? `${MEDIA_PATH}/${encodeMediaPath(video.poster_path)}` : null;
useMediaSession(mediaTitle, channel ? channel.name : null, mediaArtworkUrl);
// Keyboard shortcuts for video player controls
// k: Play/pause
useHotkeys('k', () => {
if (videoRef.current) {
if (videoRef.current.paused) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
}
}, {enableOnFormTags: false});
// j: Jump back 15 seconds
useHotkeys('j', () => {
if (videoRef.current) {
videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 15);
}
}, {enableOnFormTags: false});
// l: Jump forward 30 seconds
useHotkeys('l', () => {
if (videoRef.current) {
videoRef.current.currentTime = Math.min(
videoRef.current.duration || Infinity,
videoRef.current.currentTime + 30
);
}
}, {enableOnFormTags: false});
// n: Navigate to newer video
useHotkeys('n', () => {
const link = getVideoLink(nextFile);
if (link) {
navigate(link);
}
}, {enableOnFormTags: false}, [nextFile, navigate]);
// p: Navigate to older video
useHotkeys('p', () => {
const link = getVideoLink(prevFile);
if (link) {
navigate(link);
}
}, {enableOnFormTags: false}, [prevFile, navigate]);
// f: Toggle fullscreen
useHotkeys('f', () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
}, {enableOnFormTags: false});
const isAudioFile = videoFile?.mimetype?.startsWith('audio/');
const [showAudioOverlay, setShowAudioOverlay] = useState(!!isAudioFile);
const [audioPosterHover, setAudioPosterHover] = useState(false);
const [audioPlaying, setAudioPlaying] = useState(false);
useEffect(() => {
if (!isAudioFile) return;
setShowAudioOverlay(true);
const timer = setTimeout(() => setShowAudioOverlay(false), 3000);
return () => clearTimeout(timer);
}, [isAudioFile]);
useEffect(() => {
const el = videoRef.current;
if (!isAudioFile || !el) return;
const onPlay = () => setAudioPlaying(true);
const onPause = () => setAudioPlaying(false);
el.addEventListener('play', onPlay);
el.addEventListener('pause', onPause);
return () => { el.removeEventListener('play', onPlay); el.removeEventListener('pause', onPause); };
}, [isAudioFile]);
if (videoFile === null) {
return <VideoPlaceholder/>;
}
if (videoFile === undefined) {
return <PageContainer><Header as='h4'>Could not find video</Header></PageContainer>;
}
const handleDeleteVideo = async (fileGroupId, force = false) => {
const result = await deleteFileGroups([fileGroupId], force);
if (result && result.tagged) {
setTaggedFileGroups(result.file_groups);
return;
}
navigate(-1);
}
const handleRefresh = async () => {
await downloadVideoMetadata(videoFile.url);
};
let videoUrl = `${MEDIA_PATH}/${encodeMediaPath(video.video_path)}`;
let downloadUrl = `/download/${encodeMediaPath(video.video_path)}`;
let posterUrl = video.poster_path ? `${MEDIA_PATH}/${encodeMediaPath(video.poster_path)}` : null;
const {caption_files} = video;
const captionUrls = caption_files && caption_files.length > 0 ?
caption_files.filter(i => i['mimetype'] === 'text/vtt').map(i => `${MEDIA_PATH}/${encodeMediaPath(i['path'])}`)
: [];
const getFile = (suffix) => videoFile.files.find(i => i.path.toLowerCase().endsWith(suffix));
const fileSize = (file) => file && file.size
? <span style={{marginLeft: '1em', color: 'var(--muted)'}}>({humanFileSize(file.size)})</span>
: null;
const {poster_file, info_json_file} = video;
const videoPathFile = videoFile.files.find(i => i.path === video['video_path']);
const ffprobeJsonFile = getFile('.ffprobe.json');
const tableStyle = {width: '100%', borderCollapse: 'separate', borderSpacing: '0 0.7em'};
const labelStyle = {whiteSpace: 'nowrap', paddingRight: '1.5em', verticalAlign: 'top'};
const localAddTag = async (name) => {
await tagFileGroup(videoFile, name);
await fetchVideo();
}
const localRemoveTag = async (name) => {
await untagFileGroup(videoFile, name);
await fetchVideo();
}
let videoSource = <source src={videoUrl}/>;
const isAudio = videoFile.mimetype && videoFile.mimetype.startsWith('audio/');
let prevNextVideosPanel = <Panel><p>No related videos found.</p></Panel>;
if (prevFile || nextFile) {
prevNextVideosPanel = <Panel>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
{prevFile && <><Header as='h3'>Older</Header><VideoCard file={prevFile}/></>}
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
{nextFile && <><Header as='h3'>Newer</Header><VideoCard file={nextFile}/></>}
</Grid.Col>
</Grid>
</Panel>
}
let title = video.video_path;
if (videoFile.title) {
title = hashtagLinks(videoFile.title);
} else if (videoFile.stem) {
title = videoFile.stem;
}
const audioPlayPauseLabel = audioPlaying ? 'Pause' : 'Play';
return <>
<div className='wrolpi-button-row' style={{margin: '1em'}}>
<BackButton/>
</div>
{isAudio ? <>
{posterUrl && <div
// A wrapper whose custom play/pause/volume glyphs are drawn over the poster image: the whole
// composite must be tinted as one unit in night mode, so it carries `.media` even though the
// <img/> inside is already a filtered leaf on its own.
className='audio-poster-container media'
role='button'
tabIndex={0}
aria-label={audioPlaying ? 'Pause audio' : 'Play audio'}
onClick={() => {
if (videoRef.current) {
videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (videoRef.current) {
videoRef.current.paused ? videoRef.current.play() : videoRef.current.pause();
}
}
}}
onMouseEnter={() => setAudioPosterHover(true)}
onMouseLeave={() => setAudioPosterHover(false)}
>
<img
src={posterUrl}
alt={mediaTitle || 'Audio thumbnail'}
style={{maxWidth: '100%', maxHeight: '400px', cursor: 'pointer'}}
/>
<div
className='audio-poster-overlay'
aria-hidden='true'
style={{opacity: showAudioOverlay ? 1 : 0, transition: 'opacity 500ms ease'}}
>
<Icon name='volume up' size={64}/>
</div>
<div
className='audio-poster-overlay'
aria-hidden='true'
style={{
opacity: (audioPosterHover && !showAudioOverlay) ? 1 : 0,
transition: 'opacity 200ms ease',
}}
>
<Icon component={audioPlaying ? IconPlayerPause : IconPlayerPlay} size={64}
label={audioPlayPauseLabel}/>
</div>
</div>}
<audio controls
autoPlay={props.autoplay !== undefined ? props.autoplay : true}
id="player"
style={{width: '100%', marginTop: posterUrl ? '0.5em' : 0}}
ref={videoRef}
>
{videoSource}
</audio>
</> : <video controls
className='video-player'
autoPlay={props.autoplay !== undefined ? props.autoplay : true}
poster={posterUrl}
id="player"
playsInline={true}
style={{maxWidth: '100%'}}
ref={videoRef}
>
{videoSource}
{/* Only WebVTT captions can be displayed. */}
{captionUrls.map(i => <CaptionTrack key={i} src={i}/>)}
</video>}
{/* `wrolpi-stack`: everything below the player is stacked in here rather than in the
page, so this is what spaces it. */}
<div className='wrolpi-stack wrolpi-page-inset' style={{marginTop: '1em'}}>
<Panel>
<Header as='h2'>{title}</Header>
{videoFile.published_datetime && <h3>{isoDatetimeToAgoPopup(videoFile.published_datetime)}</h3>}
<h3>
{channel && <Link to={`/videos/channel/${channel.id}/video`}>
{channel.name}
</Link>}
</h3>
<div className='wrolpi-button-row'>
<Button component='a' href={downloadUrl} icon='download'>
Download
</Button>
<VideoEditMenu
videoFile={videoFile}
video={video}
onRefresh={handleRefresh}
onTranscodeComplete={fetchVideo}
onDelete={async () => await handleDeleteVideo(videoFile.id)}
description={videoDescription}
onSaved={async () => {
await fetchVideo();
await fetchDescription();
}}
/>
<AddToPlaylistButton fileGroupId={videoFile.id}/>
</div>
</Panel>
<Panel>
<TagsSelector selectedTagNames={videoFile['tags']} onAdd={localAddTag} onRemove={localRemoveTag}/>
</Panel>
<Panel>
<Header as='h2'>About Video</Header>
<Grid>
<Grid.Col span={6}>
<h3>Size</h3>
<p>{videoFile.size ? humanFileSize(videoFile.size) : 'Unknown'}</p>
</Grid.Col>
<Grid.Col span={6}>
<h3>View Count</h3>
<p>{video.view_count ? humanNumber(video.view_count) : 'N/A'}</p>
</Grid.Col>
<Grid.Col span={6}>
<h3>Codec Names</h3>
<>{video.codec_names ? video.codec_names.map(i => <Label key={i}>{i}</Label>) : 'N/A'}</>
</Grid.Col>
<Grid.Col span={6}>
<h3>Censored</h3>
<p>{videoFile.censored ? 'Yes' : 'No'}</p>
</Grid.Col>
<Grid.Col span={12}>
<h3>Source URL</h3>
<p>{videoFile.url ? <a href={videoFile.url}>{videoFile.url}</a> : 'N/A'}</p>
</Grid.Col>
</Grid>
</Panel>
<Tabs value={activeTab} onChange={setActiveTab} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value='comments'>Comments</Tabs.Tab>
<Tabs.Tab value='description'>Description</Tabs.Tab>
<Tabs.Tab value='files'>Files</Tabs.Tab>
<Tabs.Tab value='captions'>Captions</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value='comments' style={{paddingTop: '1em'}}>
<Header as='h3'>Top Comments</Header>
<Comments comments={comments} video={videoFile}/>
</Tabs.Panel>
<Tabs.Panel value='description' style={{paddingTop: '1em'}}>
<VideoDescription description={videoDescription} setVideoTime={setVideoTime}/>
</Tabs.Panel>
<Tabs.Panel value='files' style={{paddingTop: '1em'}}>
<table style={tableStyle}>
<tbody>
<tr>
<td style={labelStyle}><strong>{isAudio ? 'Audio File' : 'Video File'}</strong></td>
<td>
{video['video_path']
? <><PreviewPath path={video['video_path']} mimetype={isAudio ? 'audio/*' : 'video/*'} taggable={false}>
{video['video_path']}
</PreviewPath>{fileSize(videoPathFile)}</>
: 'Unknown'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Caption Files</strong></td>
<td>
{caption_files
? caption_files.map(i => <div key={i['path']} style={{marginBottom: '0.3em'}}>
<PreviewPath {...i} taggable={false}>{i['path']}</PreviewPath>
{fileSize(i)}
</div>)
: 'No caption files'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Poster File</strong></td>
<td>
{poster_file
? <><PreviewPath path={poster_file['path']} mimetype={poster_file['mimetype']}
taggable={false}>
{poster_file['path']}
</PreviewPath>{fileSize(poster_file)}</>
: 'No poster file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Info JSON File</strong></td>
<td>
{info_json_file
? <><PreviewPath path={info_json_file['path']} mimetype={info_json_file['mimetype']}
taggable={false}>
{info_json_file['path']}
</PreviewPath>{fileSize(info_json_file)}</>
: 'No yt-dlp info json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>ffprobe JSON File</strong></td>
<td>
{ffprobeJsonFile
? <><PreviewPath path={ffprobeJsonFile['path']} mimetype={ffprobeJsonFile['mimetype']}
taggable={false}>
{ffprobeJsonFile['path']}
</PreviewPath>{fileSize(ffprobeJsonFile)}</>
: 'No ffprobe json file'}
</td>
</tr>
<tr>
<td style={labelStyle}><strong>Directory</strong></td>
<td><DirectoryLink path={videoFile['directory']}/></td>
</tr>
</tbody>
</table>
</Tabs.Panel>
<Tabs.Panel value='captions' style={{paddingTop: '1em'}}>
<CaptionsPane
captions={captions}
captionsLoading={captionsLoading}
fetchCaptions={fetchCaptions}
setVideoTime={setVideoTime}
/>
</Tabs.Panel>
</Tabs>
{prevNextVideosPanel}
</div>
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}
taggedFileGroups={taggedFileGroups}
onCancel={() => setTaggedFileGroups(null)}
onConfirm={async () => {
setTaggedFileGroups(null);
await handleDeleteVideo(videoFile.id, true);
}}
/>
</>
}
function formatCaptionTimestamp(totalSeconds) {
totalSeconds = Math.floor(totalSeconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad2 = (n) => String(n).padStart(2, '0');
if (hours > 0) {
return `${hours}:${pad2(minutes)}:${pad2(seconds)}`;
}
return `${minutes}:${pad2(seconds)}`;
}
function CaptionsPane({captions, captionsLoading, fetchCaptions, setVideoTime}) {
const [filter, setFilter] = useState('');
// Trigger fetch on first render of this pane.
React.useEffect(() => {
fetchCaptions();
}, []);
if (captionsLoading) {
return <Loading/>;
}
if (!captions || captions.length === 0) {
return <div>No captions available.</div>;
}
let filtered = captions;
if (filter.trim()) {
const words = filter.toLowerCase().split(/\s+/).filter(Boolean);
filtered = captions.filter(chunk => {
const text = chunk.text.toLowerCase();
return words.every(word => text.includes(word));
});
}
return <>
<TextInput
leftSection={<Icon name='filter' size={14}/>}
placeholder='Filter captions...'
value={filter}
onChange={(e) => setFilter(e.currentTarget.value)}
style={{marginBottom: '1em', width: '100%'}}
/>
<div style={{maxHeight: '400px', overflowY: 'auto'}}>
{filtered.map((chunk, idx) => (
<div key={idx} style={{display: 'flex', gap: '1em', marginBottom: '0.4em'}}>
<a href='#'
onClick={(e) => {
e.preventDefault();
setVideoTime(chunk.start_seconds);
}}
style={{whiteSpace: 'nowrap', fontFamily: 'var(--font-mono)'}}
>
{formatCaptionTimestamp(chunk.start_seconds)}
</a>
<span>{chunk.text}</span>
</div>
))}
</div>
</>;
}
export default VideoPage;
const chapterRegex = new RegExp('^(\\(?(?:((\\d?\\d):)?(?:(\\d?\\d):(\\d\\d)))\\)?)\.?\\s+(.*)$', 'i');
// `description` follows useVideoExtras: null = pending, undefined = fetch failed, '' = none.
export const VideoDescription = ({description, setVideoTime}) => {
if (description === null) {
return <Placeholder lines={3}/>
} else if (description === undefined) {
return <ErrorMessage>Could not fetch the description.</ErrorMessage>
}
return <pre className="wrap-text">
{description ? formatVideoDescription(description, setVideoTime) : 'No description available.'}
</pre>
}
function formatVideoDescription(description, setVideoTime) {
// Convert timestamps to links which change the video's playback location. Change hashtags to search links.
if (description && description.length > 0) {
try {
const lines = _.split(description, '\n');
let newLines = [];
for (let i = 0; i <= lines.length; i++) {
const line = lines[i];
const match = line ? line.match(chapterRegex) : null;
let link;
if (match) {
// This line in the description starts with a chapter timestamp, parse it and create a link.
try {
const timestamp = match[1];
const title = match[6];
// Assume 0 if no part of timestamp could be found.
const [hour, minute, second] = match.slice(3, 6).map(i => parseInt(i) || 0);
// Convert timestamp to seconds.
const seconds = (((hour * 60) + minute) * 60) + second;
link = <>
<a href='#' onClick={() => setVideoTime(seconds)}>{timestamp}</a>
&nbsp;{title}
</>;
} catch (e) {
// Report an error while creating timestamp link.
console.error(e);
}
// Use the link if we could create it, otherwise fallback to the original `line`.
newLines = [...newLines, link || hashtagLinks(line)];
} else if (line !== undefined) {
// Line does not start with a timestamp.
newLines = [...newLines, hashtagLinks(line)];
}
}
// Map all lines and links with a break between.
return <>
{newLines.map((i, idx) => <div key={idx}>{i}<br/></div>)}
</>;
} catch (e) {
// Some error happened while parsing description. Report it but don't fail.
console.error(e);
}
}
// Error occurred, or no description, don't fail.
return description;
}
const hashtagRegex = /(#+[_a-zA-Z0-9]+)/ig;
function hashtagLinks(text) {
// Converts a string into a div which has hashtags converted to search links.
// Example: Some text <a href="/search?q=#hashtag">#hashtag</a> the rest of the text
if (text && text.length > 0) {
const parts = text.split(hashtagRegex);
let newParts = '';
for (let i = 0; i < parts.length; i++) {
let part = parts[i];
part = part.startsWith('#') ?
// Part is a hashtag.
<a key={i} href={`/search?q=${encodeURIComponent(part)}`} target="_self">{part}</a>
// Part is not a hashtag.
: <span key={i}>{part}</span>;
newParts = [...newParts, part];
}
return <React.Fragment>{newParts}</React.Fragment>;
}
return text;
}