From 84b50c158fb1bc82784e16a853ed0a1333935625 Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 30 Jul 2026 13:07:09 -0600 Subject: [PATCH] ui: migrate VideoPlayer.js off Semantic UI React Replaces semantic-ui-react and ./Theme imports with the token-driven ./ui library: Segment->Panel, Tab->Tabs (Tabs.List/Tab/Panel, keepMounted=false to preserve per-tab lazy render/fetch), Loader active->Loading, Input-> TextInput, Grid/Grid.Column->Mantine Grid/Grid.Col, Container->plain div, color=/APIButton->role=. Comment/CommentGroup and Transition have no ui equivalent, so the comment thread and audio-poster fade are rebuilt from plain elements/tokens. The audio poster wrapper (custom play/pause/volume glyphs drawn over the poster image) gets .media so night mode tints the composite as one unit; the video/audio elements and poster img stay untagged since they are already filtered leaves. Added ARIA button semantics to the poster click target since it has no native href/button. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/components/VideoPlayer.js | 439 +++++++++++++++--------------- 1 file changed, 223 insertions(+), 216 deletions(-) diff --git a/app/src/components/VideoPlayer.js b/app/src/components/VideoPlayer.js index c0604528..0e88cdc9 100644 --- a/app/src/components/VideoPlayer.js +++ b/app/src/components/VideoPlayer.js @@ -1,4 +1,4 @@ -import React, {useContext, useEffect, useState} from 'react'; +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"; @@ -17,17 +17,14 @@ import { useMediaSession, useTitle } from "./Common"; -import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; -import Container from "semantic-ui-react/dist/commonjs/elements/Container"; import {VideoPlaceholder} from "./Placeholder"; import {useChannel, useVideoCaptions, useVideoExtras} from "../hooks/customHooks"; -import {ThemeContext} from "../contexts/contexts"; -import {Button, darkTheme, Header, Icon, Loader, Segment, Tab} from "./Theme"; +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 {Comment, CommentGroup, Input, Label, Transition} from "semantic-ui-react"; import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal"; +import {IconPlayerPause, IconPlayerPlay} from "@tabler/icons-react"; const MEDIA_PATH = '/media'; @@ -117,7 +114,7 @@ const NoComments = ({video}) => {

No comments have been been downloaded. Refresh the video?

Refresh @@ -132,43 +129,43 @@ const NoComments = ({video}) => { } } +// 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 {t} = React.useContext(ThemeContext); - 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 = ; + specialIcon = ; } else if (is_favorited) { - specialIcon = ; + specialIcon = ; } - const dateElm =
{isoDatetimeToAgoPopup(timestamp * 1000)}
; - const likesElm = like_count &&
- {humanNumber(comment['like_count'])} -
; - - return - - - {specialIcon}{author} - - - {dateElm} - {likesElm} - - - - - + return
+
+ {specialIcon}{author} +
+
+
{isoDatetimeToAgoPopup(timestamp * 1000)}
+ {like_count ?
+ {humanNumber(comment['like_count'])} +
: null} +
+
+ +
{children && !_.isEmpty(children) && - +
{children.map((i, key) => )} - +
} - +
} @@ -202,9 +199,9 @@ const Comments = ({comments, video}) => { groupedByParent[obj.parent] = _.sortBy(children, ['timestamp']); }) - return + return
{rootComments.map((i, key) => )} - +
} // Generate a video page URL for a file object. @@ -214,11 +211,10 @@ function getVideoLink(file) { } function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { - const {theme} = useContext(ThemeContext); - 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; @@ -353,26 +349,9 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { description = formatVideoDescription(description, setVideoTime) } - const descriptionPane = { - menuItem: 'Description', render: () => -
-                {description}
-            
-
- }; - - const captionsPane = { - menuItem: 'Captions', render: () => - }; - const getFile = (suffix) => videoFile.files.find(i => i.path.toLowerCase().endsWith(suffix)); const fileSize = (file) => file && file.size - ? ({humanFileSize(file.size)}) + ? ({humanFileSize(file.size)}) : null; const {poster_file, info_json_file} = video; @@ -380,120 +359,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { 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 filesPane = { - menuItem: 'Files', render: () => - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{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
-
- } - - const commentsPane = { - menuItem: 'Comments', render: () => -
Top Comments
- -
, - }; - - const tabPanes = [commentsPane, descriptionPane, filesPane, captionsPane]; - const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true}; - - const aboutSegment = -
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'}

-
-
-
-
; const localAddTag = async (name) => { await tagFileGroup(videoFile, name); @@ -508,20 +373,18 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { let videoSource = ; const isAudio = videoFile.mimetype && videoFile.mimetype.startsWith('audio/'); - let prevNextVideosSegment =

No related videos found.

; + let prevNextVideosPanel =

No related videos found.

; if (prevFile || nextFile) { - prevNextVideosSegment = - - - - {prevFile && <>
Older
} -
- - {nextFile && <>
Newer
} -
-
+ prevNextVideosPanel = + + + {prevFile && <>
Older
} +
+ + {nextFile && <>
Newer
} +
-
+ } let title = video.video_path; @@ -531,19 +394,35 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { 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)} > @@ -552,16 +431,24 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { alt={mediaTitle || 'Audio thumbnail'} style={{maxWidth: '100%', maxHeight: '400px', cursor: 'pointer'}} /> - -
- -
-
- -
- -
-
+ +
}