mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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>
801 lines
32 KiB
JavaScript
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>
|
|
{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;
|
|
}
|