feat: Implement loading more search results
This commit is contained in:
@@ -9,13 +9,13 @@ import * as downloadService from "../services/downloadService";
|
|||||||
import { getVideoDuration } from "../services/metadataService";
|
import { getVideoDuration } from "../services/metadataService";
|
||||||
import * as storageService from "../services/storageService";
|
import * as storageService from "../services/storageService";
|
||||||
import {
|
import {
|
||||||
extractBilibiliVideoId,
|
extractBilibiliVideoId,
|
||||||
extractSourceVideoId,
|
extractSourceVideoId,
|
||||||
extractUrlFromText,
|
extractUrlFromText,
|
||||||
isBilibiliUrl,
|
isBilibiliUrl,
|
||||||
isValidUrl,
|
isValidUrl,
|
||||||
resolveShortUrl,
|
resolveShortUrl,
|
||||||
trimBilibiliUrl,
|
trimBilibiliUrl,
|
||||||
} from "../utils/helpers";
|
} from "../utils/helpers";
|
||||||
|
|
||||||
// Configure Multer for file uploads
|
// Configure Multer for file uploads
|
||||||
@@ -44,7 +44,14 @@ export const searchVideos = async (
|
|||||||
return res.status(400).json({ error: "Search query is required" });
|
return res.status(400).json({ error: "Search query is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await downloadService.searchYouTube(query as string);
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 8;
|
||||||
|
const offset = req.query.offset ? parseInt(req.query.offset as string) : 1;
|
||||||
|
|
||||||
|
const results = await downloadService.searchYouTube(
|
||||||
|
query as string,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
);
|
||||||
res.status(200).json({ results });
|
res.status(200).json({ results });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error searching for videos:", error);
|
console.error("Error searching for videos:", error);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type {
|
|||||||
BilibiliVideoInfo,
|
BilibiliVideoInfo,
|
||||||
BilibiliVideosResult,
|
BilibiliVideosResult,
|
||||||
CollectionDownloadResult,
|
CollectionDownloadResult,
|
||||||
DownloadResult,
|
DownloadResult
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to download Bilibili video
|
// Helper function to download Bilibili video
|
||||||
@@ -121,8 +121,12 @@ export async function downloadRemainingBilibiliParts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search for videos on YouTube (using yt-dlp)
|
// Search for videos on YouTube (using yt-dlp)
|
||||||
export async function searchYouTube(query: string): Promise<any[]> {
|
export async function searchYouTube(
|
||||||
return YtDlpDownloader.search(query);
|
query: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number
|
||||||
|
): Promise<any[]> {
|
||||||
|
return YtDlpDownloader.search(query, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download generic video (using yt-dlp)
|
// Download generic video (using yt-dlp)
|
||||||
|
|||||||
@@ -66,19 +66,31 @@ async function extractXiaoHongShuAuthor(url: string): Promise<string | null> {
|
|||||||
|
|
||||||
export class YtDlpDownloader {
|
export class YtDlpDownloader {
|
||||||
// Search for videos (primarily for YouTube, but could be adapted)
|
// Search for videos (primarily for YouTube, but could be adapted)
|
||||||
static async search(query: string): Promise<any[]> {
|
static async search(
|
||||||
console.log("Processing search request for query:", query);
|
query: string,
|
||||||
|
limit: number = 8,
|
||||||
|
offset: number = 1
|
||||||
|
): Promise<any[]> {
|
||||||
|
console.log(
|
||||||
|
`Processing search request for query: "${query}", limit: ${limit}, offset: ${offset}`
|
||||||
|
);
|
||||||
|
|
||||||
// Get user config for network options
|
// Get user config for network options
|
||||||
const userConfig = getUserYtDlpConfig();
|
const userConfig = getUserYtDlpConfig();
|
||||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||||
|
|
||||||
|
// Calculate the total number of items to fetch from search
|
||||||
|
// We need to request enough items to cover the offset + limit
|
||||||
|
const searchLimit = offset + limit - 1;
|
||||||
|
|
||||||
// Use ytsearch for searching
|
// Use ytsearch for searching
|
||||||
const searchResults = await executeYtDlpJson(`ytsearch5:${query}`, {
|
const searchResults = await executeYtDlpJson(`ytsearch${searchLimit}:${query}`, {
|
||||||
...networkConfig,
|
...networkConfig,
|
||||||
noWarnings: true,
|
noWarnings: true,
|
||||||
skipDownload: true,
|
skipDownload: true,
|
||||||
playlistEnd: 5, // Limit to 5 results
|
flatPlaylist: true, // Use flat playlist for faster search results
|
||||||
|
playlistStart: offset,
|
||||||
|
playlistEnd: searchLimit,
|
||||||
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
|
extractorArgs: `youtubepot-bgutilscript:script_path=${PROVIDER_SCRIPT}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +103,11 @@ export class YtDlpDownloader {
|
|||||||
id: entry.id,
|
id: entry.id,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
author: entry.uploader,
|
author: entry.uploader,
|
||||||
thumbnailUrl: entry.thumbnail,
|
thumbnailUrl:
|
||||||
|
entry.thumbnail ||
|
||||||
|
(entry.thumbnails && entry.thumbnails.length > 0
|
||||||
|
? entry.thumbnails[0].url
|
||||||
|
: ""),
|
||||||
duration: entry.duration,
|
duration: entry.duration,
|
||||||
viewCount: entry.view_count,
|
viewCount: entry.view_count,
|
||||||
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`, // Default to YT for search results
|
sourceUrl: `https://www.youtube.com/watch?v=${entry.id}`, // Default to YT for search results
|
||||||
@@ -99,7 +115,7 @@ export class YtDlpDownloader {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Found ${formattedResults.length} search results for "${query}"`
|
`Found ${formattedResults.length} search results for "${query}" (requested ${limit})`
|
||||||
);
|
);
|
||||||
|
|
||||||
return formattedResults;
|
return formattedResults;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface VideoContextType {
|
|||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
handleTagToggle: (tag: string) => void;
|
handleTagToggle: (tag: string) => void;
|
||||||
showYoutubeSearch: boolean;
|
showYoutubeSearch: boolean;
|
||||||
|
loadMoreSearchResults: () => Promise<void>;
|
||||||
|
loadingMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoContext = createContext<VideoContextType | undefined>(undefined);
|
const VideoContext = createContext<VideoContextType | undefined>(undefined);
|
||||||
@@ -86,6 +88,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
const [isSearchMode, setIsSearchMode] = useState<boolean>(false);
|
const [isSearchMode, setIsSearchMode] = useState<boolean>(false);
|
||||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
|
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
||||||
|
|
||||||
// Reference to the current search request's abort controller
|
// Reference to the current search request's abort controller
|
||||||
const searchAbortController = useRef<AbortController | null>(null);
|
const searchAbortController = useRef<AbortController | null>(null);
|
||||||
@@ -149,7 +152,10 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setLocalSearchResults([]);
|
setLocalSearchResults([]);
|
||||||
|
setSearchResults([]);
|
||||||
|
setLocalSearchResults([]);
|
||||||
setYoutubeLoading(false);
|
setYoutubeLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = async (query: string): Promise<any> => {
|
const handleSearch = async (query: string): Promise<any> => {
|
||||||
@@ -217,6 +223,34 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMoreSearchResults = async (): Promise<void> => {
|
||||||
|
if (!searchTerm || loadingMore || !showYoutubeSearch) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoadingMore(true);
|
||||||
|
const currentCount = searchResults.length;
|
||||||
|
const limit = 8;
|
||||||
|
const offset = currentCount + 1;
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_URL}/search`, {
|
||||||
|
params: {
|
||||||
|
query: searchTerm,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.results && response.data.results.length > 0) {
|
||||||
|
setSearchResults(prev => [...prev, ...response.data.results]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading more results:', error);
|
||||||
|
showSnackbar(t('failedToSearch'));
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTagToggle = (tag: string) => {
|
const handleTagToggle = (tag: string) => {
|
||||||
setSelectedTags(prev =>
|
setSelectedTags(prev =>
|
||||||
prev.includes(tag)
|
prev.includes(tag)
|
||||||
@@ -323,7 +357,9 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
availableTags,
|
availableTags,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
handleTagToggle,
|
handleTagToggle,
|
||||||
showYoutubeSearch
|
showYoutubeSearch,
|
||||||
|
loadMoreSearchResults,
|
||||||
|
loadingMore
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</VideoContext.Provider>
|
</VideoContext.Provider>
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ const SearchPage: React.FC = () => {
|
|||||||
youtubeLoading,
|
youtubeLoading,
|
||||||
handleSearch,
|
handleSearch,
|
||||||
searchTerm: contextSearchTerm,
|
searchTerm: contextSearchTerm,
|
||||||
showYoutubeSearch
|
showYoutubeSearch,
|
||||||
|
loadMoreSearchResults,
|
||||||
|
loadingMore
|
||||||
} = useVideo();
|
} = useVideo();
|
||||||
const { collections } = useCollection();
|
const { collections } = useCollection();
|
||||||
const { handleVideoSubmit } = useDownload();
|
const { handleVideoSubmit } = useDownload();
|
||||||
@@ -112,61 +114,73 @@ const SearchPage: React.FC = () => {
|
|||||||
<Typography sx={{ mt: 2 }}>{t('loadingYouTubeResults')}</Typography>
|
<Typography sx={{ mt: 2 }}>{t('loadingYouTubeResults')}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : hasYouTubeResults ? (
|
) : hasYouTubeResults ? (
|
||||||
<Grid container spacing={3}>
|
<>
|
||||||
{searchResults.map((result) => (
|
<Grid container spacing={3}>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
{searchResults.map((result) => (
|
||||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
||||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
<CardMedia
|
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
||||||
component="img"
|
<CardMedia
|
||||||
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
component="img"
|
||||||
alt={result.title}
|
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||||
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
alt={result.title}
|
||||||
onError={(e) => {
|
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
const target = e.target as HTMLImageElement;
|
onError={(e) => {
|
||||||
target.onerror = null;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
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' }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{result.duration && (
|
||||||
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
<Chip
|
||||||
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
|
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>
|
</Box>
|
||||||
</Box>
|
<CardContent sx={{ flexGrow: 1, p: 2 }}>
|
||||||
<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' }}>
|
||||||
<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}
|
||||||
{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>
|
</Typography>
|
||||||
)}
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
</CardContent>
|
{result.author}
|
||||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
</Typography>
|
||||||
<Button
|
{result.viewCount && (
|
||||||
fullWidth
|
<Typography variant="caption" color="text.secondary">
|
||||||
variant="contained"
|
{formatViewCount(result.viewCount)} {t('views')}
|
||||||
startIcon={<Download />}
|
</Typography>
|
||||||
onClick={() => handleDownload(result.id, result.sourceUrl)}
|
)}
|
||||||
disabled={downloadingId === result.id}
|
</CardContent>
|
||||||
>
|
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||||
{t('download')}
|
<Button
|
||||||
</Button>
|
fullWidth
|
||||||
</CardActions>
|
variant="contained"
|
||||||
</Card>
|
startIcon={<Download />}
|
||||||
</Grid>
|
onClick={() => handleDownload(result.id, result.sourceUrl)}
|
||||||
))}
|
disabled={downloadingId === result.id}
|
||||||
</Grid>
|
>
|
||||||
|
{t('download')}
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={loadMoreSearchResults}
|
||||||
|
disabled={loadingMore}
|
||||||
|
startIcon={loadingMore ? <CircularProgress size={20} color="inherit" /> : null}
|
||||||
|
>
|
||||||
|
{loadingMore ? t('loading') : t('more')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
|
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import React, { useEffect, useState } from 'react';
|
|||||||
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 { useDownload } from '../contexts/DownloadContext';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useVideo } from '../contexts/VideoContext';
|
import { useVideo } from '../contexts/VideoContext';
|
||||||
import { formatDuration } from '../utils/formatUtils';
|
import { formatDuration } from '../utils/formatUtils';
|
||||||
|
|
||||||
const SearchResults: React.FC = () => {
|
const SearchResults: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const {
|
const {
|
||||||
searchResults,
|
searchResults,
|
||||||
localSearchResults,
|
localSearchResults,
|
||||||
@@ -30,7 +32,10 @@ const SearchResults: React.FC = () => {
|
|||||||
deleteVideo,
|
deleteVideo,
|
||||||
resetSearch,
|
resetSearch,
|
||||||
setIsSearchMode,
|
setIsSearchMode,
|
||||||
showYoutubeSearch
|
|
||||||
|
showYoutubeSearch,
|
||||||
|
loadMoreSearchResults,
|
||||||
|
loadingMore
|
||||||
} = useVideo();
|
} = useVideo();
|
||||||
const { collections } = useCollection();
|
const { collections } = useCollection();
|
||||||
const { handleVideoSubmit } = useDownload();
|
const { handleVideoSubmit } = useDownload();
|
||||||
@@ -140,60 +145,72 @@ const SearchResults: React.FC = () => {
|
|||||||
<Typography sx={{ mt: 2 }}>Loading YouTube results...</Typography>
|
<Typography sx={{ mt: 2 }}>Loading YouTube results...</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : hasYouTubeResults ? (
|
) : hasYouTubeResults ? (
|
||||||
<Grid container spacing={3}>
|
<>
|
||||||
{searchResults.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
<Grid container spacing={3}>
|
||||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
{searchResults.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
||||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
<CardMedia
|
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
||||||
component="img"
|
<CardMedia
|
||||||
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
component="img"
|
||||||
alt={result.title}
|
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||||
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
alt={result.title}
|
||||||
onError={(e) => {
|
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
const target = e.target as HTMLImageElement;
|
onError={(e) => {
|
||||||
target.onerror = null;
|
const target = e.target as HTMLImageElement;
|
||||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
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' }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{result.duration && (
|
||||||
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
<Chip
|
||||||
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
|
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>
|
</Box>
|
||||||
</Box>
|
<CardContent sx={{ flexGrow: 1, p: 2 }}>
|
||||||
<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' }}>
|
||||||
<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}
|
||||||
{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)} views
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
</CardContent>
|
{result.author}
|
||||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
</Typography>
|
||||||
<Button
|
{result.viewCount && (
|
||||||
fullWidth
|
<Typography variant="caption" color="text.secondary">
|
||||||
variant="contained"
|
{formatViewCount(result.viewCount)} views
|
||||||
startIcon={<Download />}
|
</Typography>
|
||||||
onClick={() => handleDownload(result.id, result.sourceUrl)}
|
)}
|
||||||
disabled={downloadingId === result.id}
|
</CardContent>
|
||||||
>
|
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||||
Download
|
<Button
|
||||||
</Button>
|
fullWidth
|
||||||
</CardActions>
|
variant="contained"
|
||||||
</Card>
|
startIcon={<Download />}
|
||||||
|
onClick={() => handleDownload(result.id, result.sourceUrl)}
|
||||||
|
disabled={downloadingId === result.id}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
|
||||||
</Grid>
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={loadMoreSearchResults}
|
||||||
|
disabled={loadingMore}
|
||||||
|
startIcon={loadingMore ? <CircularProgress size={20} color="inherit" /> : null}
|
||||||
|
>
|
||||||
|
{loadingMore ? t('loading') : t('more')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Typography color="text.secondary">No YouTube results found.</Typography>
|
<Typography color="text.secondary">No YouTube results found.</Typography>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -444,4 +444,5 @@ export const ar = {
|
|||||||
customize: "تخصيص",
|
customize: "تخصيص",
|
||||||
hide: "إخفاء",
|
hide: "إخفاء",
|
||||||
reset: "إعادة تعيين",
|
reset: "إعادة تعيين",
|
||||||
|
more: "المزيد",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -425,4 +425,5 @@ export const de = {
|
|||||||
customize: "Anpassen",
|
customize: "Anpassen",
|
||||||
hide: "Ausblenden",
|
hide: "Ausblenden",
|
||||||
reset: "Zurücksetzen",
|
reset: "Zurücksetzen",
|
||||||
|
more: "Mehr",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -453,4 +453,5 @@ export const en = {
|
|||||||
customize: "Customize",
|
customize: "Customize",
|
||||||
hide: "Hide",
|
hide: "Hide",
|
||||||
reset: "Reset",
|
reset: "Reset",
|
||||||
|
more: "More",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -431,4 +431,5 @@ export const es = {
|
|||||||
customize: "Personalizar",
|
customize: "Personalizar",
|
||||||
hide: "Ocultar",
|
hide: "Ocultar",
|
||||||
reset: "Restablecer",
|
reset: "Restablecer",
|
||||||
|
more: "Más",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -463,4 +463,5 @@ export const fr = {
|
|||||||
customize: "Personnaliser",
|
customize: "Personnaliser",
|
||||||
hide: "Masquer",
|
hide: "Masquer",
|
||||||
reset: "Réinitialiser",
|
reset: "Réinitialiser",
|
||||||
|
more: "Plus",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -452,4 +452,5 @@ export const ja = {
|
|||||||
customize: "カスタマイズ",
|
customize: "カスタマイズ",
|
||||||
hide: "隠す",
|
hide: "隠す",
|
||||||
reset: "リセット",
|
reset: "リセット",
|
||||||
|
more: "もっと見る",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -446,4 +446,5 @@ export const ko = {
|
|||||||
customize: "사용자 지정",
|
customize: "사용자 지정",
|
||||||
hide: "숨기기",
|
hide: "숨기기",
|
||||||
reset: "초기화",
|
reset: "초기화",
|
||||||
|
more: "더 보기",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -457,4 +457,5 @@ export const pt = {
|
|||||||
customize: "Personalizar",
|
customize: "Personalizar",
|
||||||
hide: "Ocultar",
|
hide: "Ocultar",
|
||||||
reset: "Redefinir",
|
reset: "Redefinir",
|
||||||
|
more: "Mais",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -451,4 +451,5 @@ export const ru = {
|
|||||||
customize: "Настроить",
|
customize: "Настроить",
|
||||||
hide: "Скрыть",
|
hide: "Скрыть",
|
||||||
reset: "Сбросить",
|
reset: "Сбросить",
|
||||||
|
more: "Ещё",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -442,4 +442,5 @@ export const zh = {
|
|||||||
customize: "自定义",
|
customize: "自定义",
|
||||||
hide: "隐藏",
|
hide: "隐藏",
|
||||||
reset: "重置",
|
reset: "重置",
|
||||||
|
more: "更多",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user