feat: Add batch download feature
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
- **视频上传**:直接上传本地视频文件到您的库,并自动生成缩略图。
|
||||
- **Bilibili 支持**:支持下载单个视频、多P视频以及整个合集/系列。
|
||||
- **并行下载**:支持队列下载,可同时追踪多个下载任务的进度。
|
||||
- **批量下载**:一次性添加多个视频链接到下载队列。
|
||||
- **并发下载限制**:设置同时下载的数量限制以管理带宽。
|
||||
- **本地库**:自动保存视频缩略图和元数据,提供丰富的浏览体验。
|
||||
- **视频播放器**:自定义播放器,支持播放/暂停、循环、快进/快退、全屏和调光控制。
|
||||
|
||||
@@ -17,6 +17,7 @@ A YouTube/Bilibili/MissAV video downloader and player application that allows yo
|
||||
- **Video Upload**: Upload local video files directly to your library with automatic thumbnail generation.
|
||||
- **Bilibili Support**: Support for downloading single videos, multi-part videos, and entire collections/series.
|
||||
- **Parallel Downloads**: Queue multiple downloads and track their progress simultaneously.
|
||||
- **Batch Download**: Add multiple video URLs at once to the download queue.
|
||||
- **Concurrent Download Limit**: Set a limit on the number of simultaneous downloads to manage bandwidth.
|
||||
- **Local Library**: Automatically save video thumbnails and metadata for a rich browsing experience.
|
||||
- **Video Player**: Custom player with Play/Pause, Loop, Seek, Full-screen, and Dimming controls.
|
||||
|
||||
71
frontend/src/components/BatchDownloadModal.tsx
Normal file
71
frontend/src/components/BatchDownloadModal.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
TextField
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface BatchDownloadModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
const BatchDownloadModal: React.FC<BatchDownloadModalProps> = ({ open, onClose, onConfirm }) => {
|
||||
const { t } = useLanguage();
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
const urls = text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
onConfirm(urls);
|
||||
setText('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setText('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('batchDownload') || 'Batch Download'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
{t('batchDownloadDescription') || 'Paste multiple URLs below, one per line.'}
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
id="urls"
|
||||
label={t('urls') || 'URLs'}
|
||||
type="text"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={10}
|
||||
variant="outlined"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="https://www.youtube.com/watch?v=...\nhttps://www.bilibili.com/video/..."
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>{t('cancel') || 'Cancel'}</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" disabled={!text.trim()}>
|
||||
{t('addToQueue') || 'Add to Queue'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchDownloadModal;
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Brightness4,
|
||||
Brightness7,
|
||||
Clear,
|
||||
CloudUpload,
|
||||
Download,
|
||||
Menu as MenuIcon,
|
||||
Search,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
CircularProgress,
|
||||
ClickAwayListener,
|
||||
Collapse,
|
||||
Divider,
|
||||
Fade,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Menu,
|
||||
@@ -39,7 +38,7 @@ import { Collection, Video } from '../types';
|
||||
import AuthorsList from './AuthorsList';
|
||||
import Collections from './Collections';
|
||||
import TagsList from './TagsList';
|
||||
import UploadModal from './UploadModal';
|
||||
|
||||
|
||||
interface DownloadInfo {
|
||||
id: string;
|
||||
@@ -84,7 +83,6 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const [error, setError] = useState<string>('');
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [manageAnchorEl, setManageAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState<boolean>(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
@@ -166,21 +164,11 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
if (window.location.pathname === '/') {
|
||||
window.location.reload();
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const renderActionButtons = () => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={t('uploadVideo')}>
|
||||
<IconButton color="inherit" onClick={() => setUploadModalOpen(true)}>
|
||||
<CloudUpload />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
{(
|
||||
<>
|
||||
@@ -201,6 +189,8 @@ const Header: React.FC<HeaderProps> = ({
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
width: 320,
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
'& .MuiAvatar-root': {
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -224,7 +214,17 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
slots={{ transition: Fade }}
|
||||
>
|
||||
<MenuItem onClick={() => { handleDownloadsClose(); navigate('/downloads'); }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', color: 'primary.main' }}>
|
||||
<Download sx={{ mr: 1, fontSize: 20 }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{t('manageDownloads') || 'Manage Downloads'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
|
||||
{activeDownloads.map((download) => (
|
||||
<MenuItem key={download.id} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1, py: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
@@ -284,12 +284,6 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</MenuItem>
|
||||
))
|
||||
]}
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { handleDownloadsClose(); navigate('/downloads'); }}>
|
||||
<Typography variant="body2" color="primary" sx={{ fontWeight: 'bold', width: '100%', textAlign: 'center' }}>
|
||||
{t('manageDownloads') || 'Manage Downloads'}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
@@ -318,6 +312,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
overflow: 'visible',
|
||||
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||
mt: 1.5,
|
||||
width: 320,
|
||||
'&:before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
@@ -335,6 +330,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
slots={{ transition: Fade }}
|
||||
>
|
||||
<MenuItem onClick={() => { handleManageClose(); navigate('/manage'); }}>
|
||||
<VideoLibrary sx={{ mr: 2 }} /> {t('manageContent')}
|
||||
@@ -484,11 +480,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
)}
|
||||
</Toolbar>
|
||||
|
||||
<UploadModal
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
|
||||
|
||||
</AppBar>
|
||||
</ClickAwayListener>
|
||||
|
||||
@@ -92,6 +92,26 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input or textarea
|
||||
if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
handleSeek(-10);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
handleSeek(10);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
@@ -217,7 +237,7 @@ const VideoControls: React.FC<VideoControlsProps> = ({
|
||||
</Stack>
|
||||
|
||||
{/* Row 2 on Mobile: Seek Controls */}
|
||||
<Stack direction="row" spacing={1} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
|
||||
<Stack direction="row" spacing={0.5} justifyContent="center" width={{ xs: '100%', sm: 'auto' }}>
|
||||
<Tooltip title="-10m">
|
||||
<Button variant="outlined" onClick={() => handleSeek(-600)}>
|
||||
<KeyboardDoubleArrowLeft />
|
||||
|
||||
@@ -167,6 +167,33 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 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' }
|
||||
}}
|
||||
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>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
justifyContent="space-between"
|
||||
@@ -223,7 +250,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
|
||||
<Stack direction="row" spacing={3} alignItems="center" flexWrap="wrap">
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 1, sm: 3 }} alignItems={{ xs: 'flex-start', sm: 'center' }} flexWrap="wrap">
|
||||
{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' }}>
|
||||
@@ -256,8 +283,8 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
|
||||
{videoCollections.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>{t('collections')}:</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="subtitle2" sx={{ mr: 1 }}>{t('collections')}:</Typography>
|
||||
{videoCollections.map(c => (
|
||||
<Chip
|
||||
key={c.id}
|
||||
@@ -267,40 +294,14 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
clickable
|
||||
sx={{ mb: 1 }}
|
||||
size="small"
|
||||
sx={{ my: 0.5 }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 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' }
|
||||
}}
|
||||
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>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
ClearAll as ClearAllIcon,
|
||||
Delete as DeleteIcon,
|
||||
Error as ErrorIcon
|
||||
Error as ErrorIcon,
|
||||
PlaylistAdd as PlaylistAddIcon
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import BatchDownloadModal from '../components/BatchDownloadModal';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
@@ -70,9 +72,26 @@ function CustomTabPanel(props: TabPanelProps) {
|
||||
const DownloadPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { activeDownloads, queuedDownloads } = useDownload();
|
||||
const { activeDownloads, queuedDownloads, handleVideoSubmit } = useDownload();
|
||||
const queryClient = useQueryClient();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [showBatchModal, setShowBatchModal] = useState(false);
|
||||
|
||||
const handleBatchSubmit = async (urls: string[]) => {
|
||||
// We'll process them sequentially to be safe, or just fire them all.
|
||||
// Let's fire them all but with a small delay or just let the context handle it.
|
||||
// Since handleVideoSubmit is async, we can await them.
|
||||
let addedCount = 0;
|
||||
for (const url of urls) {
|
||||
if (url.trim()) {
|
||||
await handleVideoSubmit(url.trim());
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
if (addedCount > 0) {
|
||||
showSnackbar(t('batchTasksAdded', { count: addedCount }) || `${addedCount} tasks added`);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch history with polling
|
||||
const { data: history = [] } = useQuery({
|
||||
@@ -211,9 +230,20 @@ const DownloadPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', p: 2 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||
{t('downloads') || 'Downloads'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<PlaylistAddIcon />}
|
||||
onClick={() => setShowBatchModal(true)}
|
||||
>
|
||||
{t('addBatchTasks') || 'Add batch tasks'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} aria-label="download tabs">
|
||||
<Tab label={t('activeDownloads') || 'Active Downloads'} />
|
||||
@@ -368,6 +398,12 @@ const DownloadPage: React.FC = () => {
|
||||
</List>
|
||||
)}
|
||||
</CustomTabPanel>
|
||||
|
||||
<BatchDownloadModal
|
||||
open={showBatchModal}
|
||||
onClose={() => setShowBatchModal(false)}
|
||||
onConfirm={handleBatchSubmit}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ArrowBack,
|
||||
Check,
|
||||
Close,
|
||||
CloudUpload,
|
||||
Delete,
|
||||
Edit,
|
||||
Folder,
|
||||
@@ -34,6 +35,7 @@ import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import DeleteCollectionModal from '../components/DeleteCollectionModal';
|
||||
import UploadModal from '../components/UploadModal';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
@@ -52,6 +54,7 @@ const ManagePage: React.FC = () => {
|
||||
const [isDeletingCollection, setIsDeletingCollection] = useState<boolean>(false);
|
||||
const [videoToDelete, setVideoToDelete] = useState<string | null>(null);
|
||||
const [showVideoDeleteModal, setShowVideoDeleteModal] = useState<boolean>(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState<boolean>(false);
|
||||
|
||||
// Editing state
|
||||
const [editingVideoId, setEditingVideoId] = useState<string | null>(null);
|
||||
@@ -227,12 +230,24 @@ const ManagePage: React.FC = () => {
|
||||
return video.thumbnailUrl || 'https://via.placeholder.com/120x90?text=No+Thumbnail';
|
||||
};
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
{t('manageContent')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
{t('uploadVideo')}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
@@ -242,6 +257,13 @@ const ManagePage: React.FC = () => {
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<UploadModal
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
|
||||
<DeleteCollectionModal
|
||||
isOpen={!!collectionToDelete}
|
||||
|
||||
@@ -388,12 +388,14 @@ const VideoPlayer: React.FC = () => {
|
||||
onTagsUpdate={handleUpdateTags}
|
||||
/>
|
||||
|
||||
{(video.source === 'youtube' || video.source === 'bilibili') && (
|
||||
<CommentsSection
|
||||
comments={comments}
|
||||
loading={loadingComments}
|
||||
showComments={showComments}
|
||||
onToggleComments={handleToggleComments}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
@@ -435,8 +437,8 @@ const VideoPlayer: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<CardContent sx={{ flex: '1 0 auto', p: 1, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="body2" fontWeight="bold" sx={{ lineHeight: 1.2, mb: 0.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
<CardContent sx={{ flex: '1 1 auto', minWidth: 0, p: 1, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="body2" fontWeight="bold" sx={{ lineHeight: 1.2, mb: 0.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block' }}>
|
||||
{relatedVideo.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
@@ -445,11 +447,9 @@ const VideoPlayer: React.FC = () => {
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{formatDate(relatedVideo.date)}
|
||||
</Typography>
|
||||
{relatedVideo.viewCount !== undefined && (
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
{relatedVideo.viewCount} {t('views')}
|
||||
{relatedVideo.viewCount || 0} {t('views')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -265,4 +265,12 @@ export const ar = {
|
||||
speed: "السرعة",
|
||||
finishedAt: "انتهى في",
|
||||
failed: "فشل",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "تحميل مجمع",
|
||||
batchDownloadDescription: "الصق روابط متعددة أدناه، واحد في كل سطر.",
|
||||
urls: "الروابط",
|
||||
addToQueue: "إضافة إلى قائمة الانتظار",
|
||||
batchTasksAdded: "تمت إضافة {count} مهام",
|
||||
addBatchTasks: "إضافة مهام مجمعة",
|
||||
};
|
||||
|
||||
@@ -136,4 +136,12 @@ export const de = {
|
||||
speed: "Geschwindigkeit",
|
||||
finishedAt: "Beendet am",
|
||||
failed: "Fehlgeschlagen",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Stapel-Download",
|
||||
batchDownloadDescription: "Fügen Sie unten mehrere URLs ein, eine pro Zeile.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Zur Warteschlange hinzufügen",
|
||||
batchTasksAdded: "{count} Aufgaben hinzugefügt",
|
||||
addBatchTasks: "Stapelaufgaben hinzufügen",
|
||||
};
|
||||
|
||||
@@ -266,4 +266,12 @@ export const en = {
|
||||
videoRemovedFromCollection: "Video removed from collection",
|
||||
collectionDeletedSuccessfully: "Collection deleted successfully",
|
||||
failedToDeleteCollection: "Failed to delete collection",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Batch Download",
|
||||
batchDownloadDescription: "Paste multiple URLs below, one per line.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Add to Queue",
|
||||
batchTasksAdded: "{count} tasks added",
|
||||
addBatchTasks: "Add batch tasks",
|
||||
};
|
||||
|
||||
@@ -134,4 +134,12 @@ export const es = {
|
||||
speed: "Velocidad",
|
||||
finishedAt: "Finalizado en",
|
||||
failed: "Fallido",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Descarga por lotes",
|
||||
batchDownloadDescription: "Pegue varias URL a continuación, una por línea.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Añadir a la cola",
|
||||
batchTasksAdded: "{count} tareas añadidas",
|
||||
addBatchTasks: "Añadir tareas por lotes",
|
||||
};
|
||||
|
||||
@@ -264,4 +264,12 @@ export const fr = {
|
||||
speed: "Vitesse",
|
||||
finishedAt: "Terminé à",
|
||||
failed: "Échoué",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Téléchargement par lot",
|
||||
batchDownloadDescription: "Collez plusieurs URL ci-dessous, une par ligne.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Ajouter à la file d'attente",
|
||||
batchTasksAdded: "{count} tâches ajoutées",
|
||||
addBatchTasks: "Ajouter des tâches par lot",
|
||||
};
|
||||
|
||||
@@ -265,4 +265,12 @@ export const ja = {
|
||||
speed: "速度",
|
||||
finishedAt: "完了日時",
|
||||
failed: "失敗",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "一括ダウンロード",
|
||||
batchDownloadDescription: "以下に複数のURLを1行に1つずつ貼り付けてください。",
|
||||
urls: "URL",
|
||||
addToQueue: "キューに追加",
|
||||
batchTasksAdded: "{count} 件のタスクを追加しました",
|
||||
addBatchTasks: "一括タスクを追加",
|
||||
};
|
||||
|
||||
@@ -265,4 +265,12 @@ export const ko = {
|
||||
speed: "속도",
|
||||
finishedAt: "완료 시간",
|
||||
failed: "실패",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "일괄 다운로드",
|
||||
batchDownloadDescription: "아래에 여러 URL을 한 줄에 하나씩 붙여넣으세요.",
|
||||
urls: "URL",
|
||||
addToQueue: "대기열에 추가",
|
||||
batchTasksAdded: "{count}개의 작업이 추가되었습니다",
|
||||
addBatchTasks: "일괄 작업 추가",
|
||||
};
|
||||
|
||||
@@ -264,4 +264,12 @@ export const pt = {
|
||||
speed: "Velocidade",
|
||||
finishedAt: "Terminado em",
|
||||
failed: "Falhou",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Download em lote",
|
||||
batchDownloadDescription: "Cole vários URLs abaixo, um por linha.",
|
||||
urls: "URLs",
|
||||
addToQueue: "Adicionar à fila",
|
||||
batchTasksAdded: "{count} tarefas adicionadas",
|
||||
addBatchTasks: "Adicionar tarefas em lote",
|
||||
};
|
||||
|
||||
@@ -265,4 +265,12 @@ export const ru = {
|
||||
speed: "Скорость",
|
||||
finishedAt: "Завершено в",
|
||||
failed: "Ошибка",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "Пакетная загрузка",
|
||||
batchDownloadDescription: "Вставьте несколько URL ниже, по одному в строке.",
|
||||
urls: "URL",
|
||||
addToQueue: "Добавить в очередь",
|
||||
batchTasksAdded: "Добавлено {count} задач",
|
||||
addBatchTasks: "Добавить пакетные задачи",
|
||||
};
|
||||
|
||||
@@ -266,4 +266,12 @@ export const zh = {
|
||||
speed: "速度",
|
||||
finishedAt: "完成时间",
|
||||
failed: "失败",
|
||||
|
||||
// Batch Download
|
||||
batchDownload: "批量下载",
|
||||
batchDownloadDescription: "在下方粘贴多个链接,每行一个。",
|
||||
urls: "链接",
|
||||
addToQueue: "添加到队列",
|
||||
batchTasksAdded: "已添加 {count} 个任务",
|
||||
addBatchTasks: "添加批量任务",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user