feat: Add SearchPage component and route
This commit is contained in:
@@ -18,6 +18,7 @@ import Home from './pages/Home';
|
|||||||
import InstructionPage from './pages/InstructionPage';
|
import InstructionPage from './pages/InstructionPage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import ManagePage from './pages/ManagePage';
|
import ManagePage from './pages/ManagePage';
|
||||||
|
import SearchPage from './pages/SearchPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import SubscriptionsPage from './pages/SubscriptionsPage';
|
import SubscriptionsPage from './pages/SubscriptionsPage';
|
||||||
import VideoPlayer from './pages/VideoPlayer';
|
import VideoPlayer from './pages/VideoPlayer';
|
||||||
@@ -107,6 +108,7 @@ function AppContent() {
|
|||||||
<Box component="main" sx={{ flexGrow: 1, p: 0, width: '100%', overflowX: 'hidden' }}>
|
<Box component="main" sx={{ flexGrow: 1, p: 0, width: '100%', overflowX: 'hidden' }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/search" element={<SearchPage />} />
|
||||||
<Route path="/manage" element={<ManagePage />} />
|
<Route path="/manage" element={<ManagePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/downloads" element={<DownloadPage />} />
|
<Route path="/downloads" element={<DownloadPage />} />
|
||||||
|
|||||||
@@ -137,26 +137,16 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
setVideoUrl('');
|
setVideoUrl('');
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
} else if (result.isSearchTerm) {
|
} else if (result.isSearchTerm) {
|
||||||
const searchResult = await onSearch(videoUrl);
|
setVideoUrl('');
|
||||||
if (searchResult.success) {
|
setMobileMenuOpen(false);
|
||||||
setVideoUrl('');
|
navigate(`/search?q=${encodeURIComponent(videoUrl)}`);
|
||||||
setMobileMenuOpen(false);
|
|
||||||
navigate('/');
|
|
||||||
} else {
|
|
||||||
setError(searchResult.error);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await onSearch(videoUrl);
|
setVideoUrl('');
|
||||||
if (result.success) {
|
setMobileMenuOpen(false);
|
||||||
setVideoUrl('');
|
navigate(`/search?q=${encodeURIComponent(videoUrl)}`);
|
||||||
setMobileMenuOpen(false);
|
|
||||||
navigate('/');
|
|
||||||
} else {
|
|
||||||
setError(result.error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(t('unexpectedErrorOccurred'));
|
setError(t('unexpectedErrorOccurred'));
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { ArrowBack, Collections as CollectionsIcon, Download, GridView, OndemandVideo, ViewSidebar, YouTube } from '@mui/icons-material';
|
|
||||||
|
import { Collections as CollectionsIcon, GridView, ViewSidebar } from '@mui/icons-material';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
CardActions,
|
|
||||||
CardContent,
|
|
||||||
CardMedia,
|
|
||||||
Chip,
|
|
||||||
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Collapse,
|
Collapse,
|
||||||
Container,
|
Container,
|
||||||
@@ -26,7 +21,6 @@ import Collections from '../components/Collections';
|
|||||||
import TagsList from '../components/TagsList';
|
import TagsList from '../components/TagsList';
|
||||||
import VideoCard from '../components/VideoCard';
|
import VideoCard from '../components/VideoCard';
|
||||||
import { useCollection } from '../contexts/CollectionContext';
|
import { useCollection } from '../contexts/CollectionContext';
|
||||||
import { useDownload } from '../contexts/DownloadContext';
|
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useVideo } from '../contexts/VideoContext';
|
import { useVideo } from '../contexts/VideoContext';
|
||||||
|
|
||||||
@@ -38,20 +32,12 @@ const Home: React.FC = () => {
|
|||||||
videos,
|
videos,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
deleteVideo,
|
|
||||||
availableTags,
|
availableTags,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
handleTagToggle,
|
handleTagToggle
|
||||||
isSearchMode,
|
|
||||||
searchTerm,
|
|
||||||
localSearchResults,
|
|
||||||
searchResults,
|
|
||||||
youtubeLoading,
|
|
||||||
setIsSearchMode,
|
|
||||||
resetSearch
|
|
||||||
} = useVideo();
|
} = useVideo();
|
||||||
const { collections } = useCollection();
|
const { collections } = useCollection();
|
||||||
const { handleVideoSubmit } = useDownload();
|
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const ITEMS_PER_PAGE = 12;
|
const ITEMS_PER_PAGE = 12;
|
||||||
@@ -112,19 +98,12 @@ const Home: React.FC = () => {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, [videos, collections, selectedTags]);
|
}, [videos, collections, selectedTags]);
|
||||||
|
|
||||||
const handleDownload = async (url: string) => {
|
|
||||||
try {
|
|
||||||
setIsSearchMode(false);
|
|
||||||
await handleVideoSubmit(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error downloading from search:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add default empty array to ensure videos is always an array
|
// Add default empty array to ensure videos is always an array
|
||||||
const videoArray = Array.isArray(videos) ? videos : [];
|
const videoArray = Array.isArray(videos) ? videos : [];
|
||||||
|
|
||||||
if (!settingsLoaded || (loading && videoArray.length === 0 && !isSearchMode)) {
|
if (!settingsLoaded || (loading && videoArray.length === 0)) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
@@ -133,7 +112,7 @@ const Home: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && videoArray.length === 0 && !isSearchMode) {
|
if (error && videoArray.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Container sx={{ mt: 4 }}>
|
<Container sx={{ mt: 4 }}>
|
||||||
<Alert severity="error">{error}</Alert>
|
<Alert severity="error">{error}</Alert>
|
||||||
@@ -196,141 +175,6 @@ const Home: React.FC = () => {
|
|||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to format duration in seconds to MM:SS
|
|
||||||
const formatDuration = (seconds?: number) => {
|
|
||||||
if (!seconds) return '';
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = Math.floor(seconds % 60);
|
|
||||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to format view count
|
|
||||||
const formatViewCount = (count?: number) => {
|
|
||||||
if (!count) return '0';
|
|
||||||
if (count < 1000) return count.toString();
|
|
||||||
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
|
|
||||||
return `${(count / 1000000).toFixed(1)}M`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// If in search mode, show search results
|
|
||||||
if (isSearchMode) {
|
|
||||||
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
|
|
||||||
const hasYouTubeResults = searchResults && searchResults.length > 0;
|
|
||||||
|
|
||||||
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('searchResultsFor')} "{searchTerm}"
|
|
||||||
</Typography>
|
|
||||||
{resetSearch && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<ArrowBack />}
|
|
||||||
onClick={resetSearch}
|
|
||||||
>
|
|
||||||
{t('backToHome')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Local Video Results */}
|
|
||||||
<Box sx={{ mb: 6 }}>
|
|
||||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: 'primary.main' }}>
|
|
||||||
{t('fromYourLibrary')}
|
|
||||||
</Typography>
|
|
||||||
{hasLocalResults ? (
|
|
||||||
<Grid container spacing={3}>
|
|
||||||
{localSearchResults.map((video) => (
|
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
|
|
||||||
<VideoCard
|
|
||||||
video={video}
|
|
||||||
collections={collections}
|
|
||||||
onDeleteVideo={deleteVideo}
|
|
||||||
showDeleteButton={true}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
) : (
|
|
||||||
<Typography color="text.secondary">{t('noMatchingVideos')}</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* YouTube Search Results */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
|
|
||||||
{t('fromYouTube')}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{youtubeLoading ? (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
|
||||||
<CircularProgress color="error" />
|
|
||||||
<Typography sx={{ mt: 2 }}>{t('loadingYouTubeResults')}</Typography>
|
|
||||||
</Box>
|
|
||||||
) : hasYouTubeResults ? (
|
|
||||||
<Grid container spacing={3}>
|
|
||||||
{searchResults.map((result) => (
|
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
|
||||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
|
||||||
<CardMedia
|
|
||||||
component="img"
|
|
||||||
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
|
||||||
alt={result.title}
|
|
||||||
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.onerror = null;
|
|
||||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{result.duration && (
|
|
||||||
<Chip
|
|
||||||
label={formatDuration(result.duration)}
|
|
||||||
size="small"
|
|
||||||
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
|
||||||
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<CardContent sx={{ flexGrow: 1, p: 2 }}>
|
|
||||||
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
||||||
{result.title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
{result.author}
|
|
||||||
</Typography>
|
|
||||||
{result.viewCount && (
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{formatViewCount(result.viewCount)} {t('views')}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<Download />}
|
|
||||||
onClick={() => handleDownload(result.sourceUrl)}
|
|
||||||
>
|
|
||||||
{t('download')}
|
|
||||||
</Button>
|
|
||||||
</CardActions>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
) : (
|
|
||||||
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular home view (not in search mode)
|
// Regular home view (not in search mode)
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
ArrowBack,
|
|
||||||
Check,
|
Check,
|
||||||
Close,
|
Close,
|
||||||
|
|
||||||
@@ -270,14 +269,6 @@ const ManagePage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{scanMutation.isPending ? (t('scanning') || 'Scanning...') : (t('scanFiles') || 'Scan Files')}
|
{scanMutation.isPending ? (t('scanning') || 'Scanning...') : (t('scanFiles') || 'Scan Files')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to="/"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<ArrowBack />}
|
|
||||||
>
|
|
||||||
{t('backToHome')}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
184
frontend/src/pages/SearchPage.tsx
Normal file
184
frontend/src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { Download, OndemandVideo, YouTube } from '@mui/icons-material';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
CardMedia,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import VideoCard from '../components/VideoCard';
|
||||||
|
import { useCollection } from '../contexts/CollectionContext';
|
||||||
|
import { useDownload } from '../contexts/DownloadContext';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useVideo } from '../contexts/VideoContext';
|
||||||
|
|
||||||
|
const SearchPage: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const {
|
||||||
|
deleteVideo,
|
||||||
|
localSearchResults,
|
||||||
|
searchResults,
|
||||||
|
youtubeLoading,
|
||||||
|
handleSearch,
|
||||||
|
resetSearch,
|
||||||
|
searchTerm: contextSearchTerm
|
||||||
|
} = useVideo();
|
||||||
|
const { collections } = useCollection();
|
||||||
|
const { handleVideoSubmit } = useDownload();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const query = searchParams.get('q');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query && query !== contextSearchTerm) {
|
||||||
|
handleSearch(query);
|
||||||
|
}
|
||||||
|
}, [query, contextSearchTerm, handleSearch]);
|
||||||
|
|
||||||
|
const handleDownload = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await handleVideoSubmit(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading from search:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
resetSearch();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to format duration in seconds to MM:SS
|
||||||
|
const formatDuration = (seconds?: number) => {
|
||||||
|
if (!seconds) return '';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to format view count
|
||||||
|
const formatViewCount = (count?: number) => {
|
||||||
|
if (!count) return '0';
|
||||||
|
if (count < 1000) return count.toString();
|
||||||
|
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
|
||||||
|
return `${(count / 1000000).toFixed(1)}M`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
|
||||||
|
const hasYouTubeResults = searchResults && searchResults.length > 0;
|
||||||
|
|
||||||
|
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('searchResultsFor')} "{query}"
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Local Video Results */}
|
||||||
|
<Box sx={{ mb: 6 }}>
|
||||||
|
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: 'primary.main' }}>
|
||||||
|
{t('fromYourLibrary')}
|
||||||
|
</Typography>
|
||||||
|
{hasLocalResults ? (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{localSearchResults.map((video) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
|
||||||
|
<VideoCard
|
||||||
|
video={video}
|
||||||
|
collections={collections}
|
||||||
|
onDeleteVideo={deleteVideo}
|
||||||
|
showDeleteButton={true}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary">{t('noMatchingVideos')}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* YouTube Search Results */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
|
||||||
|
{t('fromYouTube')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{youtubeLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||||
|
<CircularProgress color="error" />
|
||||||
|
<Typography sx={{ mt: 2 }}>{t('loadingYouTubeResults')}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : hasYouTubeResults ? (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{searchResults.map((result) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
||||||
|
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||||
|
alt={result.title}
|
||||||
|
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.onerror = null;
|
||||||
|
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{result.duration && (
|
||||||
|
<Chip
|
||||||
|
label={formatDuration(result.duration)}
|
||||||
|
size="small"
|
||||||
|
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
||||||
|
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<CardContent sx={{ flexGrow: 1, p: 2 }}>
|
||||||
|
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||||
|
{result.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
{result.author}
|
||||||
|
</Typography>
|
||||||
|
{result.viewCount && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatViewCount(result.viewCount)} {t('views')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Download />}
|
||||||
|
onClick={() => handleDownload(result.sourceUrl)}
|
||||||
|
>
|
||||||
|
{t('download')}
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchPage;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
ArrowBack,
|
|
||||||
CloudUpload,
|
CloudUpload,
|
||||||
Save
|
Save
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
@@ -27,7 +26,6 @@ import {
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import ConfirmationModal from '../components/ConfirmationModal';
|
import ConfirmationModal from '../components/ConfirmationModal';
|
||||||
import { useDownload } from '../contexts/DownloadContext';
|
import { useDownload } from '../contexts/DownloadContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -277,14 +275,6 @@ const SettingsPage: React.FC = () => {
|
|||||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||||
{t('settings')}
|
{t('settings')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to="/"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<ArrowBack />}
|
|
||||||
>
|
|
||||||
{t('backToHome')}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Card variant="outlined">
|
<Card variant="outlined">
|
||||||
|
|||||||
@@ -147,6 +147,24 @@ const VideoPlayer: React.FC = () => {
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Format duration (seconds or MM:SS)
|
||||||
|
const formatDuration = (duration: string | number | undefined) => {
|
||||||
|
if (!duration) return '00:00';
|
||||||
|
|
||||||
|
// If it's already a string with colon, assume it's formatted
|
||||||
|
if (typeof duration === 'string' && duration.includes(':')) {
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise treat as seconds
|
||||||
|
const seconds = typeof duration === 'string' ? parseInt(duration, 10) : duration;
|
||||||
|
if (isNaN(seconds)) return '00:00';
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Handle navigation to author videos page
|
// Handle navigation to author videos page
|
||||||
const handleAuthorClick = () => {
|
const handleAuthorClick = () => {
|
||||||
if (video) {
|
if (video) {
|
||||||
@@ -499,7 +517,7 @@ const VideoPlayer: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
{relatedVideo.duration && (
|
{relatedVideo.duration && (
|
||||||
<Chip
|
<Chip
|
||||||
label={relatedVideo.duration || '00:00'}
|
label={formatDuration(relatedVideo.duration)}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
Reference in New Issue
Block a user