feat: Added components for video title editing, rating, tags, author info, description, and metadata

This commit is contained in:
Peifan Li
2025-12-15 19:24:56 -05:00
parent c2c6f064f7
commit abb79d4b14
10 changed files with 784 additions and 618 deletions

View File

@@ -1,43 +1,14 @@
import {
Add,
CalendarToday,
Check,
Close,
Delete,
Download,
Edit,
ExpandLess,
ExpandMore,
Folder,
HighQuality,
Link as LinkIcon,
LocalOffer,
PlayArrow,
Share,
VideoLibrary
} from '@mui/icons-material';
import {
Alert,
Autocomplete,
Avatar,
Box,
Button,
Chip,
Divider,
ListItemText,
Menu,
MenuItem,
Rating,
Stack,
TextField,
Tooltip,
Typography,
useTheme
} from '@mui/material';
import React, { useEffect, useRef, useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useSnackbar } from '../../contexts/SnackbarContext';
import { Alert, Box, Divider, Stack } from '@mui/material';
import React from 'react';
import { useVideoResolution } from '../../hooks/useVideoResolution';
import { Collection, Video } from '../../types';
import EditableTitle from './VideoInfo/EditableTitle';
import VideoActionButtons from './VideoInfo/VideoActionButtons';
import VideoAuthorInfo from './VideoInfo/VideoAuthorInfo';
import VideoDescription from './VideoInfo/VideoDescription';
import VideoMetadata from './VideoInfo/VideoMetadata';
import VideoRating from './VideoInfo/VideoRating';
import VideoTags from './VideoInfo/VideoTags';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
@@ -70,268 +41,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
availableTags,
onTagsUpdate
}) => {
const theme = useTheme();
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
const [editedTitle, setEditedTitle] = useState<string>('');
const [isTitleExpanded, setIsTitleExpanded] = useState(false);
const [showExpandButton, setShowExpandButton] = useState(false);
const titleRef = useRef<HTMLHeadingElement>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [showDescriptionExpandButton, setShowDescriptionExpandButton] = useState(false);
const descriptionRef = useRef<HTMLParagraphElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [detectedResolution, setDetectedResolution] = useState<string | null>(null);
const [playerMenuAnchor, setPlayerMenuAnchor] = useState<null | HTMLElement>(null);
useEffect(() => {
const checkOverflow = () => {
const element = titleRef.current;
if (element && !isTitleExpanded) {
setShowExpandButton(element.scrollHeight > element.clientHeight);
}
};
checkOverflow();
window.addEventListener('resize', checkOverflow);
return () => window.removeEventListener('resize', checkOverflow);
}, [video.title, isTitleExpanded]);
useEffect(() => {
const checkDescriptionOverflow = () => {
const element = descriptionRef.current;
if (element && !isDescriptionExpanded) {
setShowDescriptionExpandButton(element.scrollHeight > element.clientHeight);
}
};
checkDescriptionOverflow();
window.addEventListener('resize', checkDescriptionOverflow);
return () => window.removeEventListener('resize', checkDescriptionOverflow);
}, [video.description, isDescriptionExpanded]);
// Get video resolution from video element
useEffect(() => {
const videoElement = videoRef.current;
const videoSrc = video.videoPath || video.sourceUrl;
if (!videoElement || !videoSrc) {
setDetectedResolution(null);
return;
}
// Set the video source
const fullVideoUrl = video.videoPath ? `${BACKEND_URL}${video.videoPath}` : video.sourceUrl;
if (videoElement.src !== fullVideoUrl) {
videoElement.src = fullVideoUrl;
videoElement.load(); // Force reload
}
const handleLoadedMetadata = () => {
const height = videoElement.videoHeight;
if (height && height > 0) {
if (height >= 2160) setDetectedResolution('4K');
else if (height >= 1440) setDetectedResolution('1440P');
else if (height >= 1080) setDetectedResolution('1080P');
else if (height >= 720) setDetectedResolution('720P');
else if (height >= 480) setDetectedResolution('480P');
else if (height >= 360) setDetectedResolution('360P');
else if (height >= 240) setDetectedResolution('240P');
else if (height >= 144) setDetectedResolution('144P');
else setDetectedResolution(null);
} else {
setDetectedResolution(null);
}
};
const handleError = () => {
setDetectedResolution(null);
};
videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
videoElement.addEventListener('error', handleError);
// If metadata is already loaded
if (videoElement.readyState >= 1 && videoElement.videoHeight > 0) {
handleLoadedMetadata();
}
return () => {
videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata);
videoElement.removeEventListener('error', handleError);
};
}, [video.videoPath, video.sourceUrl, video.id]);
const handleStartEditingTitle = () => {
setEditedTitle(video.title);
setIsEditingTitle(true);
};
const handleCancelEditingTitle = () => {
setIsEditingTitle(false);
setEditedTitle('');
};
const handleSaveTitle = async () => {
if (!editedTitle.trim()) return;
await onTitleSave(editedTitle);
setIsEditingTitle(false);
};
const handleRatingChangeInternal = (_: React.SyntheticEvent, newValue: number | null) => {
if (newValue) {
onRatingChange(newValue);
}
};
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: video.title,
text: `Check out this video: ${video.title}`,
url: window.location.href,
});
} catch (error) {
console.error('Error sharing:', error);
}
} else {
const url = window.location.href;
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(url);
showSnackbar(t('linkCopied'), 'success');
} catch (error) {
console.error('Error copying to clipboard:', error);
showSnackbar(t('copyFailed'), 'error');
}
} else {
// Fallback for secure context requirement or unsupported browsers
const textArea = document.createElement("textarea");
textArea.value = url;
textArea.style.position = "fixed"; // Avoid scrolling to bottom
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showSnackbar(t('linkCopied'), 'success');
} else {
showSnackbar(t('copyFailed'), 'error');
}
} catch (err) {
console.error('Fallback: Unable to copy', err);
showSnackbar(t('copyFailed'), 'error');
}
document.body.removeChild(textArea);
}
}
};
// Format the date (assuming format YYYYMMDD from youtube-dl)
const formatDate = (dateString?: string) => {
if (!dateString || dateString.length !== 8) {
return 'Unknown date';
}
const year = dateString.substring(0, 4);
const month = dateString.substring(4, 6);
const day = dateString.substring(6, 8);
return `${year}-${month}-${day}`;
};
// Format video resolution
const formatResolution = (video: Video): string | null => {
// Check if resolution is directly available
if (video.resolution) {
const res = String(video.resolution).toUpperCase();
// If it's already formatted like "720P", return it
if (res.match(/^\d+P$/)) {
return res;
}
// If it's "4K" or similar, return it
if (res.match(/^\d+K$/)) {
return res;
}
}
// Check if width and height are available
const width = (video as any).width;
const height = (video as any).height;
if (width && height) {
const h = typeof height === 'number' ? height : parseInt(String(height));
if (!isNaN(h)) {
if (h >= 2160) return '4K';
if (h >= 1440) return '1440P';
if (h >= 1080) return '1080P';
if (h >= 720) return '720P';
if (h >= 480) return '480P';
if (h >= 360) return '360P';
if (h >= 240) return '240P';
if (h >= 144) return '144P';
}
}
// Check if there's a format_id or format that might contain resolution info
const formatId = (video as any).format_id;
if (formatId && typeof formatId === 'string') {
const match = formatId.match(/(\d+)p/i);
if (match) {
return match[1].toUpperCase() + 'P';
}
}
return null;
};
const handleOpenPlayerMenu = (event: React.MouseEvent<HTMLElement>) => {
setPlayerMenuAnchor(event.currentTarget);
};
const handleClosePlayerMenu = () => {
setPlayerMenuAnchor(null);
};
const handlePlayInPlayer = (scheme: string) => {
const videoUrl = `${BACKEND_URL}${video.videoPath || video.sourceUrl}`;
let url = '';
switch (scheme) {
case 'iina':
url = `iina://weblink?url=${videoUrl}`;
break;
case 'vlc':
url = `vlc://${videoUrl}`;
break;
case 'potplayer':
url = `potplayer://${videoUrl}`;
break;
case 'mpv':
url = `mpv://${videoUrl}`;
break;
case 'infuse':
url = `infuse://x-callback-url/play?url=${videoUrl}`;
break;
}
if (url) {
window.location.href = url;
}
handleClosePlayerMenu();
};
// Try to get resolution from video object first, fallback to detected resolution
const resolutionFromObject = formatResolution(video);
const videoResolution = resolutionFromObject || detectedResolution;
const { videoRef, videoResolution } = useVideoResolution(video);
return (
<Box sx={{ mt: 2 }}>
@@ -353,145 +63,20 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
crossOrigin="anonymous"
/>
)}
{isEditingTitle ? (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
<TextField
fullWidth
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
variant="outlined"
size="small"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveTitle();
}
}}
/>
<Button
variant="contained"
color="primary"
onClick={handleSaveTitle}
sx={{ minWidth: 'auto', p: 0.5 }}
>
<Check />
</Button>
<Button
variant="outlined"
color="secondary"
onClick={handleCancelEditingTitle}
sx={{ minWidth: 'auto', p: 0.5 }}
>
<Close />
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'flex-start', mb: 1 }}>
<Typography
ref={titleRef}
variant="h5"
component="h1"
fontWeight="bold"
onClick={() => showExpandButton && setIsTitleExpanded(!isTitleExpanded)}
sx={{
mr: 1,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: isTitleExpanded ? 'unset' : 2,
wordBreak: 'break-word',
flex: 1,
cursor: showExpandButton ? 'pointer' : 'default'
}}
>
{video.title}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Tooltip title={t('editTitle')}>
<Button
size="small"
onClick={handleStartEditingTitle}
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
>
<Edit fontSize="small" />
</Button>
</Tooltip>
{showExpandButton && (
<Tooltip title={isTitleExpanded ? t('collapse') : t('expand')}>
<Button
size="small"
onClick={() => setIsTitleExpanded(!isTitleExpanded)}
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
>
{isTitleExpanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</Button>
</Tooltip>
)}
</Box>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Rating
value={video.rating || 0}
onChange={handleRatingChangeInternal}
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
{video.rating ? `` : t('rateThisVideo')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 2 }}>
{video.viewCount || 0} {t('views')}
</Typography>
</Box>
<EditableTitle title={video.title} onSave={onTitleSave} />
{/* Tags Section */}
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<LocalOffer color="action" fontSize="small" />
<Autocomplete
multiple
options={availableTags}
value={video.tags || []}
isOptionEqualToValue={(option, value) => option === value}
onChange={(_, newValue) => onTagsUpdate(newValue)}
slotProps={{
chip: { variant: 'outlined', size: 'small' },
listbox: {
sx: {
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
p: 1
}
}
}}
renderOption={(props, option, { selected }) => {
const { key, ...otherProps } = props;
return (
<li key={key} {...otherProps} style={{ width: 'auto', padding: 0 }}>
<Chip
label={option}
size="small"
variant={selected ? "filled" : "outlined"}
color={selected ? "primary" : "default"}
sx={{ pointerEvents: 'none' }}
/>
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
sx={{ minWidth: 200 }}
slotProps={{
input: { ...params.InputProps, disableUnderline: true }
}}
/>
)}
sx={{ flexGrow: 1 }}
/>
</Box>
<VideoRating
rating={video.rating}
viewCount={video.viewCount}
onRatingChange={onRatingChange}
/>
<VideoTags
tags={video.tags}
availableTags={availableTags}
onTagsUpdate={onTagsUpdate}
/>
<Stack
direction="row"
@@ -500,198 +85,36 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
spacing={2}
sx={{ mb: 2 }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ bgcolor: 'primary.main', mr: 2 }}>
{video.author ? video.author.charAt(0).toUpperCase() : 'A'}
</Avatar>
<Box>
<Typography
variant="subtitle1"
fontWeight="bold"
onClick={onAuthorClick}
sx={{ cursor: 'pointer', '&:hover': { color: 'primary.main' } }}
>
{video.author}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(video.date)}
</Typography>
</Box>
</Box>
<VideoAuthorInfo
author={video.author}
date={video.date}
onAuthorClick={onAuthorClick}
/>
<Stack direction="row" spacing={1}>
<Tooltip title={t('openInExternalPlayer')}>
<Button
variant="outlined"
color="inherit"
onClick={handleOpenPlayerMenu}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<PlayArrow />
</Button>
</Tooltip>
<Menu
anchorEl={playerMenuAnchor}
open={Boolean(playerMenuAnchor)}
onClose={handleClosePlayerMenu}
>
<MenuItem disabled>
<Typography variant="caption" color="text.secondary">
{t('playWith')}
</Typography>
</MenuItem>
<Divider />
<MenuItem onClick={() => handlePlayInPlayer('iina')}>
<ListItemText>IINA</ListItemText>
</MenuItem>
<MenuItem onClick={() => handlePlayInPlayer('vlc')}>
<ListItemText>VLC</ListItemText>
</MenuItem>
<MenuItem onClick={() => handlePlayInPlayer('potplayer')}>
<ListItemText>PotPlayer</ListItemText>
</MenuItem>
<MenuItem onClick={() => handlePlayInPlayer('mpv')}>
<ListItemText>MPV</ListItemText>
</MenuItem>
<MenuItem onClick={() => handlePlayInPlayer('infuse')}>
<ListItemText>Infuse</ListItemText>
</MenuItem>
</Menu>
<Tooltip title={t('share')}>
<Button
variant="outlined"
color="inherit"
onClick={handleShare}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<Share />
</Button>
</Tooltip>
<Tooltip title={t('addToCollection')}>
<Button
variant="outlined"
color="inherit"
onClick={() => onAddToCollection()}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<Add />
</Button>
</Tooltip>
<Tooltip title={t('delete')}>
<Button
variant="outlined"
color="inherit"
onClick={onDelete}
disabled={isDeleting}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
>
<Delete />
</Button>
</Tooltip>
</Stack>
<VideoActionButtons
video={video}
onAddToCollection={onAddToCollection}
onDelete={onDelete}
isDeleting={isDeleting}
/>
</Stack>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }}>
{deleteError}
</Alert>
)}
{video.description && (
<Box sx={{ mt: 2 }}>
<Typography
ref={descriptionRef}
variant="body2"
color="text.primary"
sx={{
whiteSpace: 'pre-wrap',
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3,
}}
>
{video.description}
</Typography>
{showDescriptionExpandButton && (
<Button
size="small"
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
startIcon={isDescriptionExpanded ? <ExpandLess /> : <ExpandMore />}
sx={{ mt: 0.5, p: 0, minWidth: 'auto', textTransform: 'none' }}
>
{isDescriptionExpanded ? t('collapse') : t('expand')}
</Button>
)}
</Box>
)}
<VideoDescription description={video.description} />
<Divider sx={{ my: 2 }} />
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', columnGap: 3, rowGap: 1 }}>
{video.sourceUrl && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<LinkIcon fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('originalLink')}</strong>
</a>
</Typography>
)}
{video.videoPath && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<a href={`${BACKEND_URL}${video.videoPath}`} download style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<Download fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('download')}</strong>
</a>
</Typography>
)}
{videoCollections.length > 0 && (
<Box sx={{ display: 'inline', alignItems: 'center' }}>
{videoCollections.map((c, index) => (
<React.Fragment key={c.id}>
<span
onClick={() => onCollectionClick(c.id)}
style={{
cursor: 'pointer',
color: theme.palette.primary.main,
fontWeight: 'bold',
display: 'inline-flex',
alignItems: 'center',
verticalAlign: 'bottom'
}}
>
<Folder fontSize="small" sx={{ mr: 0.5 }} />
{c.name}
</span>
{index < videoCollections.length - 1 ? <span style={{ marginRight: '4px' }}>, </span> : ''}
</React.Fragment>
))}
</Box>
)}
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<VideoLibrary fontSize="small" sx={{ mr: 0.5 }} />
{video.source ? video.source.charAt(0).toUpperCase() + video.source.slice(1) : 'Unknown'}
</Typography>
{video.addedAt && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarToday fontSize="small" sx={{ mr: 0.5 }} />
{new Date(video.addedAt).toISOString().split('T')[0]}
</Typography>
)}
{videoResolution && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<HighQuality fontSize="small" sx={{ mr: 0.5 }} />
{videoResolution && `${videoResolution}`}
</Typography>
)}
</Box>
</Box>
<VideoMetadata
video={video}
videoCollections={videoCollections}
onCollectionClick={onCollectionClick}
videoResolution={videoResolution}
/>
</Box>
);
};

View File

@@ -0,0 +1,132 @@
import { Check, Close, Edit, ExpandLess, ExpandMore } from '@mui/icons-material';
import { Box, Button, TextField, Tooltip, Typography } from '@mui/material';
import React, { useEffect, useRef, useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
interface EditableTitleProps {
title: string;
onSave: (newTitle: string) => Promise<void>;
}
const EditableTitle: React.FC<EditableTitleProps> = ({ title, onSave }) => {
const { t } = useLanguage();
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
const [editedTitle, setEditedTitle] = useState<string>('');
const [isTitleExpanded, setIsTitleExpanded] = useState(false);
const [showExpandButton, setShowExpandButton] = useState(false);
const titleRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
const checkOverflow = () => {
const element = titleRef.current;
if (element && !isTitleExpanded) {
setShowExpandButton(element.scrollHeight > element.clientHeight);
}
};
checkOverflow();
window.addEventListener('resize', checkOverflow);
return () => window.removeEventListener('resize', checkOverflow);
}, [title, isTitleExpanded]);
const handleStartEditingTitle = () => {
setEditedTitle(title);
setIsEditingTitle(true);
};
const handleCancelEditingTitle = () => {
setIsEditingTitle(false);
setEditedTitle('');
};
const handleSaveTitle = async () => {
if (!editedTitle.trim()) return;
await onSave(editedTitle);
setIsEditingTitle(false);
};
if (isEditingTitle) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
<TextField
fullWidth
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
variant="outlined"
size="small"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSaveTitle();
}
}}
/>
<Button
variant="contained"
color="primary"
onClick={handleSaveTitle}
sx={{ minWidth: 'auto', p: 0.5 }}
>
<Check />
</Button>
<Button
variant="outlined"
color="secondary"
onClick={handleCancelEditingTitle}
sx={{ minWidth: 'auto', p: 0.5 }}
>
<Close />
</Button>
</Box>
);
}
return (
<Box sx={{ display: 'flex', alignItems: 'flex-start', mb: 1 }}>
<Typography
ref={titleRef}
variant="h5"
component="h1"
fontWeight="bold"
onClick={() => showExpandButton && setIsTitleExpanded(!isTitleExpanded)}
sx={{
mr: 1,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: isTitleExpanded ? 'unset' : 2,
wordBreak: 'break-word',
flex: 1,
cursor: showExpandButton ? 'pointer' : 'default'
}}
>
{title}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Tooltip title={t('editTitle')}>
<Button
size="small"
onClick={handleStartEditingTitle}
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
>
<Edit fontSize="small" />
</Button>
</Tooltip>
{showExpandButton && (
<Tooltip title={isTitleExpanded ? t('collapse') : t('expand')}>
<Button
size="small"
onClick={() => setIsTitleExpanded(!isTitleExpanded)}
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
>
{isTitleExpanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</Button>
</Tooltip>
)}
</Box>
</Box>
);
};
export default EditableTitle;

View File

@@ -0,0 +1,138 @@
import { Add, Delete, PlayArrow, Share } from '@mui/icons-material';
import { Button, Divider, ListItemText, Menu, MenuItem, Stack, Tooltip, Typography } from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useShareVideo } from '../../../hooks/useShareVideo';
import { Video } from '../../../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface VideoActionButtonsProps {
video: Video;
onAddToCollection: () => void;
onDelete: () => void;
isDeleting: boolean;
}
const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
video,
onAddToCollection,
onDelete,
isDeleting
}) => {
const { t } = useLanguage();
const { handleShare } = useShareVideo(video);
const [playerMenuAnchor, setPlayerMenuAnchor] = useState<null | HTMLElement>(null);
const handleOpenPlayerMenu = (event: React.MouseEvent<HTMLElement>) => {
setPlayerMenuAnchor(event.currentTarget);
};
const handleClosePlayerMenu = () => {
setPlayerMenuAnchor(null);
};
const handlePlayInPlayer = (scheme: string) => {
const videoUrl = `${BACKEND_URL}${video.videoPath || video.sourceUrl}`;
let url = '';
switch (scheme) {
case 'iina':
url = `iina://weblink?url=${videoUrl}`;
break;
case 'vlc':
url = `vlc://${videoUrl}`;
break;
case 'potplayer':
url = `potplayer://${videoUrl}`;
break;
case 'mpv':
url = `mpv://${videoUrl}`;
break;
case 'infuse':
url = `infuse://x-callback-url/play?url=${videoUrl}`;
break;
}
if (url) {
window.location.href = url;
}
handleClosePlayerMenu();
};
return (
<Stack direction="row" spacing={1}>
<Tooltip title={t('openInExternalPlayer')}>
<Button
variant="outlined"
color="inherit"
onClick={handleOpenPlayerMenu}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<PlayArrow />
</Button>
</Tooltip>
<Menu
anchorEl={playerMenuAnchor}
open={Boolean(playerMenuAnchor)}
onClose={handleClosePlayerMenu}
>
<MenuItem disabled>
<Typography variant="caption" color="text.secondary">
{t('playWith')}
</Typography>
</MenuItem>
<Divider />
<MenuItem onClick={() => handlePlayInPlayer('iina')}>
<ListItemText>IINA</ListItemText>
</MenuItem>
<MenuItem onClick={() => handlePlayInPlayer('vlc')}>
<ListItemText>VLC</ListItemText>
</MenuItem>
<MenuItem onClick={() => handlePlayInPlayer('potplayer')}>
<ListItemText>PotPlayer</ListItemText>
</MenuItem>
<MenuItem onClick={() => handlePlayInPlayer('mpv')}>
<ListItemText>MPV</ListItemText>
</MenuItem>
<MenuItem onClick={() => handlePlayInPlayer('infuse')}>
<ListItemText>Infuse</ListItemText>
</MenuItem>
</Menu>
<Tooltip title={t('share')}>
<Button
variant="outlined"
color="inherit"
onClick={handleShare}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<Share />
</Button>
</Tooltip>
<Tooltip title={t('addToCollection')}>
<Button
variant="outlined"
color="inherit"
onClick={() => onAddToCollection()}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<Add />
</Button>
</Tooltip>
<Tooltip title={t('delete')}>
<Button
variant="outlined"
color="inherit"
onClick={onDelete}
disabled={isDeleting}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
>
<Delete />
</Button>
</Tooltip>
</Stack>
);
};
export default VideoActionButtons;

View File

@@ -0,0 +1,47 @@
import { Avatar, Box, Typography } from '@mui/material';
import React from 'react';
interface VideoAuthorInfoProps {
author: string;
date: string | undefined;
onAuthorClick: () => void;
}
// Format the date (assuming format YYYYMMDD from youtube-dl)
const formatDate = (dateString?: string) => {
if (!dateString || dateString.length !== 8) {
return 'Unknown date';
}
const year = dateString.substring(0, 4);
const month = dateString.substring(4, 6);
const day = dateString.substring(6, 8);
return `${year}-${month}-${day}`;
};
const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({ author, date, onAuthorClick }) => {
return (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ bgcolor: 'primary.main', mr: 2 }}>
{author ? author.charAt(0).toUpperCase() : 'A'}
</Avatar>
<Box>
<Typography
variant="subtitle1"
fontWeight="bold"
onClick={onAuthorClick}
sx={{ cursor: 'pointer', '&:hover': { color: 'primary.main' } }}
>
{author}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(date)}
</Typography>
</Box>
</Box>
);
};
export default VideoAuthorInfo;

View File

@@ -0,0 +1,64 @@
import { ExpandLess, ExpandMore } from '@mui/icons-material';
import { Box, Button, Typography } from '@mui/material';
import React, { useEffect, useRef, useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
interface VideoDescriptionProps {
description: string | undefined;
}
const VideoDescription: React.FC<VideoDescriptionProps> = ({ description }) => {
const { t } = useLanguage();
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [showDescriptionExpandButton, setShowDescriptionExpandButton] = useState(false);
const descriptionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const checkDescriptionOverflow = () => {
const element = descriptionRef.current;
if (element && !isDescriptionExpanded) {
setShowDescriptionExpandButton(element.scrollHeight > element.clientHeight);
}
};
checkDescriptionOverflow();
window.addEventListener('resize', checkDescriptionOverflow);
return () => window.removeEventListener('resize', checkDescriptionOverflow);
}, [description, isDescriptionExpanded]);
if (!description) {
return null;
}
return (
<Box sx={{ mt: 2 }}>
<Typography
ref={descriptionRef}
variant="body2"
color="text.primary"
sx={{
whiteSpace: 'pre-wrap',
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: isDescriptionExpanded ? 'unset' : 3,
}}
>
{description}
</Typography>
{showDescriptionExpandButton && (
<Button
size="small"
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
startIcon={isDescriptionExpanded ? <ExpandLess /> : <ExpandMore />}
sx={{ mt: 0.5, p: 0, minWidth: 'auto', textTransform: 'none' }}
>
{isDescriptionExpanded ? t('collapse') : t('expand')}
</Button>
)}
</Box>
);
};
export default VideoDescription;

View File

@@ -0,0 +1,89 @@
import { CalendarToday, Download, Folder, HighQuality, Link as LinkIcon, VideoLibrary } from '@mui/icons-material';
import { Box, Typography, useTheme } from '@mui/material';
import React from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { Collection, Video } from '../../../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface VideoMetadataProps {
video: Video;
videoCollections: Collection[];
onCollectionClick: (id: string) => void;
videoResolution: string | null;
}
const VideoMetadata: React.FC<VideoMetadataProps> = ({
video,
videoCollections,
onCollectionClick,
videoResolution
}) => {
const theme = useTheme();
const { t } = useLanguage();
return (
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', columnGap: 3, rowGap: 1 }}>
{video.sourceUrl && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<LinkIcon fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('originalLink')}</strong>
</a>
</Typography>
)}
{video.videoPath && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<a href={`${BACKEND_URL}${video.videoPath}`} download style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
<Download fontSize="small" sx={{ mr: 0.5 }} />
<strong>{t('download')}</strong>
</a>
</Typography>
)}
{videoCollections.length > 0 && (
<Box sx={{ display: 'inline', alignItems: 'center' }}>
{videoCollections.map((c, index) => (
<React.Fragment key={c.id}>
<span
onClick={() => onCollectionClick(c.id)}
style={{
cursor: 'pointer',
color: theme.palette.primary.main,
fontWeight: 'bold',
display: 'inline-flex',
alignItems: 'center',
verticalAlign: 'bottom'
}}
>
<Folder fontSize="small" sx={{ mr: 0.5 }} />
{c.name}
</span>
{index < videoCollections.length - 1 ? <span style={{ marginRight: '4px' }}>, </span> : ''}
</React.Fragment>
))}
</Box>
)}
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<VideoLibrary fontSize="small" sx={{ mr: 0.5 }} />
{video.source ? video.source.charAt(0).toUpperCase() + video.source.slice(1) : 'Unknown'}
</Typography>
{video.addedAt && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<CalendarToday fontSize="small" sx={{ mr: 0.5 }} />
{new Date(video.addedAt).toISOString().split('T')[0]}
</Typography>
)}
{videoResolution && (
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
<HighQuality fontSize="small" sx={{ mr: 0.5 }} />
{videoResolution && `${videoResolution}`}
</Typography>
)}
</Box>
</Box>
);
};
export default VideoMetadata;

View File

@@ -0,0 +1,37 @@
import { Box, Rating, Typography } from '@mui/material';
import React from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
interface VideoRatingProps {
rating: number | undefined;
viewCount: number | undefined;
onRatingChange: (newRating: number) => Promise<void>;
}
const VideoRating: React.FC<VideoRatingProps> = ({ rating, viewCount, onRatingChange }) => {
const { t } = useLanguage();
const handleRatingChangeInternal = (_: React.SyntheticEvent, newValue: number | null) => {
if (newValue) {
onRatingChange(newValue);
}
};
return (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Rating
value={rating || 0}
onChange={handleRatingChangeInternal}
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
{rating ? `` : t('rateThisVideo')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 2 }}>
{viewCount || 0} {t('views')}
</Typography>
</Box>
);
};
export default VideoRating;

View File

@@ -0,0 +1,67 @@
import { LocalOffer } from '@mui/icons-material';
import { Autocomplete, Box, Chip, TextField } from '@mui/material';
import React from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
interface VideoTagsProps {
tags: string[] | undefined;
availableTags: string[];
onTagsUpdate: (tags: string[]) => Promise<void>;
}
const VideoTags: React.FC<VideoTagsProps> = ({ tags, availableTags, onTagsUpdate }) => {
const { t } = useLanguage();
return (
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
<LocalOffer color="action" fontSize="small" />
<Autocomplete
multiple
options={availableTags}
value={tags || []}
isOptionEqualToValue={(option, value) => option === value}
onChange={(_, newValue) => onTagsUpdate(newValue)}
slotProps={{
chip: { variant: 'outlined', size: 'small' },
listbox: {
sx: {
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
p: 1
}
}
}}
renderOption={(props, option, { selected }) => {
const { key, ...otherProps } = props;
return (
<li key={key} {...otherProps} style={{ width: 'auto', padding: 0 }}>
<Chip
label={option}
size="small"
variant={selected ? "filled" : "outlined"}
color={selected ? "primary" : "default"}
sx={{ pointerEvents: 'none' }}
/>
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
variant="standard"
placeholder={!tags || tags.length === 0 ? (t('tags') || 'Tags') : ''}
sx={{ minWidth: 200 }}
slotProps={{
input: { ...params.InputProps, disableUnderline: true }
}}
/>
)}
sx={{ flexGrow: 1 }}
/>
</Box>
);
};
export default VideoTags;

View File

@@ -0,0 +1,58 @@
import { useSnackbar } from '../contexts/SnackbarContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Video } from '../types';
export const useShareVideo = (video: Video) => {
const { showSnackbar } = useSnackbar();
const { t } = useLanguage();
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: video.title,
text: `Check out this video: ${video.title}`,
url: window.location.href,
});
} catch (error) {
console.error('Error sharing:', error);
}
} else {
const url = window.location.href;
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(url);
showSnackbar(t('linkCopied'), 'success');
} catch (error) {
console.error('Error copying to clipboard:', error);
showSnackbar(t('copyFailed'), 'error');
}
} else {
// Fallback for secure context requirement or unsupported browsers
const textArea = document.createElement("textarea");
textArea.value = url;
textArea.style.position = "fixed"; // Avoid scrolling to bottom
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showSnackbar(t('linkCopied'), 'success');
} else {
showSnackbar(t('copyFailed'), 'error');
}
} catch (err) {
console.error('Fallback: Unable to copy', err);
showSnackbar(t('copyFailed'), 'error');
}
document.body.removeChild(textArea);
}
}
};
return { handleShare };
};

View File

@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from 'react';
import { Video } from '../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
// Format video resolution from video object
export const formatResolution = (video: Video): string | null => {
// Check if resolution is directly available
if (video.resolution) {
const res = String(video.resolution).toUpperCase();
// If it's already formatted like "720P", return it
if (res.match(/^\d+P$/)) {
return res;
}
// If it's "4K" or similar, return it
if (res.match(/^\d+K$/)) {
return res;
}
}
// Check if width and height are available
const width = (video as any).width;
const height = (video as any).height;
if (width && height) {
const h = typeof height === 'number' ? height : parseInt(String(height));
if (!isNaN(h)) {
if (h >= 2160) return '4K';
if (h >= 1440) return '1440P';
if (h >= 1080) return '1080P';
if (h >= 720) return '720P';
if (h >= 480) return '480P';
if (h >= 360) return '360P';
if (h >= 240) return '240P';
if (h >= 144) return '144P';
}
}
// Check if there's a format_id or format that might contain resolution info
const formatId = (video as any).format_id;
if (formatId && typeof formatId === 'string') {
const match = formatId.match(/(\d+)p/i);
if (match) {
return match[1].toUpperCase() + 'P';
}
}
return null;
};
export const useVideoResolution = (video: Video) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [detectedResolution, setDetectedResolution] = useState<string | null>(null);
useEffect(() => {
const videoElement = videoRef.current;
const videoSrc = video.videoPath || video.sourceUrl;
if (!videoElement || !videoSrc) {
setDetectedResolution(null);
return;
}
// Set the video source
const fullVideoUrl = video.videoPath ? `${BACKEND_URL}${video.videoPath}` : video.sourceUrl;
if (videoElement.src !== fullVideoUrl) {
videoElement.src = fullVideoUrl;
videoElement.load(); // Force reload
}
const handleLoadedMetadata = () => {
const height = videoElement.videoHeight;
if (height && height > 0) {
if (height >= 2160) setDetectedResolution('4K');
else if (height >= 1440) setDetectedResolution('1440P');
else if (height >= 1080) setDetectedResolution('1080P');
else if (height >= 720) setDetectedResolution('720P');
else if (height >= 480) setDetectedResolution('480P');
else if (height >= 360) setDetectedResolution('360P');
else if (height >= 240) setDetectedResolution('240P');
else if (height >= 144) setDetectedResolution('144P');
else setDetectedResolution(null);
} else {
setDetectedResolution(null);
}
};
const handleError = () => {
setDetectedResolution(null);
};
videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
videoElement.addEventListener('error', handleError);
// If metadata is already loaded
if (videoElement.readyState >= 1 && videoElement.videoHeight > 0) {
handleLoadedMetadata();
}
return () => {
videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata);
videoElement.removeEventListener('error', handleError);
};
}, [video.videoPath, video.sourceUrl, video.id]);
// Try to get resolution from video object first, fallback to detected resolution
const resolutionFromObject = formatResolution(video);
const videoResolution = resolutionFromObject || detectedResolution;
return { videoRef, videoResolution };
};