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 } else if (src.endsWith('.es.vtt')) { return } else if (src.endsWith('.fr.vtt')) { return } else if (src.endsWith('.id.vtt')) { return } else if (src.endsWith('.it.vtt')) { return } else if (src.endsWith('.ja.vtt')) { return } else if (src.endsWith('.pl.vtt')) { return } else if (src.endsWith('.pt.vtt')) { return } else if (src.endsWith('.ro.vtt')) { return } else if (src.endsWith('.ru.vtt')) { return } else if (src.endsWith('.th.vtt')) { return } return } const NoComments = ({video}) => { const videoUrl = video.url; const handleRefresh = async () => { await downloadVideoMetadata(videoUrl); }; if (videoUrl) { return

No comments have been been downloaded. Refresh the video?

Refresh
} else { return

No comment have been downloaded. WROLPi does not know the URL of this video, so it cannot download the comments.

Try finding and downloading the video again.
} } // Semantic's / 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 = ; } else if (is_favorited) { specialIcon = ; } return
{specialIcon}{author}
{isoDatetimeToAgoPopup(timestamp * 1000)}
{like_count ?
{humanNumber(comment['like_count'])}
: null}
{children && !_.isEmpty(children) &&
{children.map((i, key) => )}
}
} const Comments = ({comments, video}) => { if (!comments || _.isEmpty(comments)) { return } 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
{rootComments.map((i, key) => )}
} // 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 ; } if (videoFile === undefined) { return
Could not find video
; } 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 ? ({humanFileSize(file.size)}) : 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 = ; const isAudio = videoFile.mimetype && videoFile.mimetype.startsWith('audio/'); let prevNextVideosPanel =

No related videos found.

; if (prevFile || nextFile) { prevNextVideosPanel = {prevFile && <>
Older
}
{nextFile && <>
Newer
}
} 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 <>
{isAudio ? <> {posterUrl &&
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)} > {mediaTitle
} : } {/* `wrolpi-stack`: everything below the player is stacked in here rather than in the page, so this is what spaces it. */}
{title}
{videoFile.published_datetime &&

{isoDatetimeToAgoPopup(videoFile.published_datetime)}

}

{channel && {channel.name} }

await handleDeleteVideo(videoFile.id)} obeyWROLMode={true} >Delete Refresh
About Video

Size

{videoFile.size ? humanFileSize(videoFile.size) : 'Unknown'}

View Count

{video.view_count ? humanNumber(video.view_count) : 'N/A'}

Codec Names

<>{video.codec_names ? video.codec_names.map(i => ) : 'N/A'}

Censored

{videoFile.censored ? 'Yes' : 'No'}

Source URL

{videoFile.url ? {videoFile.url} : 'N/A'}

Comments Description Files Captions
Top Comments
                        {description}
                    
{isAudio ? 'Audio File' : 'Video File'} {video['video_path'] ? <> {video['video_path']} {fileSize(videoPathFile)} : 'Unknown'}
Caption Files {caption_files ? caption_files.map(i =>
{i['path']} {fileSize(i)}
) : 'No caption files'}
Poster File {poster_file ? <> {poster_file['path']} {fileSize(poster_file)} : 'No poster file'}
Info JSON File {info_json_file ? <> {info_json_file['path']} {fileSize(info_json_file)} : 'No yt-dlp info json file'}
ffprobe JSON File {ffprobeJsonFile ? <> {ffprobeJsonFile['path']} {fileSize(ffprobeJsonFile)} : 'No ffprobe json file'}
Directory
{prevNextVideosPanel}
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 ; } if (!captions || captions.length === 0) { return
No captions available.
; } 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 <> } placeholder='Filter captions...' value={filter} onChange={(e) => setFilter(e.currentTarget.value)} style={{marginBottom: '1em', width: '100%'}} /> ; } 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 = <> setVideoTime(seconds)}>{timestamp}  {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) =>
{i}
)} ; } 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 #hashtag 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. {part} // Part is not a hashtag. : {part}; newParts = [...newParts, part]; } return {newParts}; } return text; }