mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Reported: the Back button fills the page width on the domain and channel edit pages. It fills it on all nine pages that have one, and this is mine from the last PR. `.wrolpi-stack` is a flex column at `align-items: stretch`, deliberately -- a panel was full-width as a block element and has to stay that way. A BARE button as a direct child of that stack is stretched too, and every page's Back button was one. On the domain, channel and playlist edit pages it also pushed the button beside it onto its own line, where the two had been inline. Wrapping each in a `.wrolpi-button-row` fixes both without touching the stack: the row is the stretched child, and it lays its buttons out at their own width. The three pages that have a second button get one row holding both, which is how they read before. Also the Videos, Archives and Documents select bars, whose Tag/Delete/Invert/ Clear were four touching buttons in a bare div. They are inline elements rather than interpolations, which is the shape the bare-siblings scan does not see. Guarded twice: the row keeps its buttons their own width inside a stack while itself stretching (both halves, since a row that refused to stretch would pass a test that only looked at the button), and every `<BackButton/>` in the app is inside a row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
791 lines
32 KiB
JavaScript
791 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,
|
|
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, Tabs, TextInput} from "./ui";
|
|
import {VideoCard} from "./Videos";
|
|
import {TagsSelector} from "../Tags";
|
|
import {AddToPlaylistButton} from "./AddToPlaylist";
|
|
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
|
|
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>
|
|
}
|
|
}
|
|
|
|
// Semantic's <Comment>/<CommentGroup> have no equivalent in the token library -- 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>
|
|
}
|
|
|
|
|
|
const Comments = ({comments, video}) => {
|
|
if (!comments || _.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} = 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'])}`)
|
|
: [];
|
|
|
|
let description = 'No description available.';
|
|
if (video && video['description']) {
|
|
// Only replace empty description if there is one available.
|
|
description = video['description'];
|
|
description = formatVideoDescription(description, setVideoTime)
|
|
}
|
|
|
|
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' 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>
|
|
|
|
<p>
|
|
<Button component='a' href={downloadUrl} icon='download'>
|
|
Download
|
|
</Button>
|
|
<APIButton
|
|
role='danger'
|
|
confirmContent='Are you sure you want to delete this video? All files related to this video will be deleted. It will not be downloaded again!'
|
|
confirmButton='Delete'
|
|
onClick={async () => await handleDeleteVideo(videoFile.id)}
|
|
obeyWROLMode={true}
|
|
>Delete</APIButton>
|
|
<APIButton
|
|
role='primary'
|
|
onClick={handleRefresh}
|
|
obeyWROLMode={true}
|
|
disabled={!videoFile.url}
|
|
>Refresh</APIButton>
|
|
<AddToPlaylistButton fileGroupId={videoFile.id}/>
|
|
</p>
|
|
</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'}}>
|
|
<pre className="wrap-text">
|
|
{description}
|
|
</pre>
|
|
</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');
|
|
|
|
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;
|
|
}
|