feat: Added components for video title editing, rating, tags, author info, description, and metadata
This commit is contained in:
@@ -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' }}
|
||||
<VideoRating
|
||||
rating={video.rating}
|
||||
viewCount={video.viewCount}
|
||||
onRatingChange={onRatingChange}
|
||||
/>
|
||||
</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 }
|
||||
}}
|
||||
|
||||
<VideoTags
|
||||
tags={video.tags}
|
||||
availableTags={availableTags}
|
||||
onTagsUpdate={onTagsUpdate}
|
||||
/>
|
||||
)}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -500,97 +85,19 @@ 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>
|
||||
<VideoActionButtons
|
||||
video={video}
|
||||
onAddToCollection={onAddToCollection}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
|
||||
{deleteError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
@@ -598,100 +105,16 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
132
frontend/src/components/VideoPlayer/VideoInfo/EditableTitle.tsx
Normal file
132
frontend/src/components/VideoPlayer/VideoInfo/EditableTitle.tsx
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
67
frontend/src/components/VideoPlayer/VideoInfo/VideoTags.tsx
Normal file
67
frontend/src/components/VideoPlayer/VideoInfo/VideoTags.tsx
Normal 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;
|
||||
|
||||
58
frontend/src/hooks/useShareVideo.ts
Normal file
58
frontend/src/hooks/useShareVideo.ts
Normal 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 };
|
||||
};
|
||||
|
||||
111
frontend/src/hooks/useVideoResolution.ts
Normal file
111
frontend/src/hooks/useVideoResolution.ts
Normal 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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user