新增也许你想看功能

This commit is contained in:
mtvpls
2026-04-13 10:35:16 +08:00
parent 3cb54df62b
commit 7469cc1537
+210 -55
View File
@@ -73,6 +73,7 @@ import ProxyImage from '@/components/ProxyImage';
import { useSite } from '@/components/SiteProvider';
import SmartRecommendations from '@/components/SmartRecommendations';
import Toast, { ToastProps } from '@/components/Toast';
import VideoCard from '@/components/VideoCard';
import { useDownload } from '@/contexts/DownloadContext';
@@ -91,6 +92,14 @@ interface WakeLockSentinel {
removeEventListener(type: 'release', listener: () => void): void;
}
interface PlayFallbackRecommendation {
key: string;
item: SearchResult;
episodes?: number;
sourceNames: string[];
doubanId?: number;
}
function PlayPageClient() {
const LOCAL_TRANSCODER_BASE_URL = 'http://localhost:19080';
const router = useRouter();
@@ -1518,6 +1527,8 @@ function PlayPageClient() {
const [sourceSearchError, setSourceSearchError] = useState<string | null>(
null
);
const [fallbackRecommendations, setFallbackRecommendations] = useState<PlayFallbackRecommendation[]>([]);
const [hasCompletedSearchRequest, setHasCompletedSearchRequest] = useState(false);
const [backgroundSourcesLoading, setBackgroundSourcesLoading] = useState(false);
// 优选和测速开关
@@ -3414,8 +3425,109 @@ function PlayPageClient() {
};
const buildFallbackRecommendations = (items: SearchResult[], query: string): PlayFallbackRecommendation[] => {
const typedItems = searchType ? items.filter((item) => getType(item) === searchType) : items;
const preliminaryMap = new Map<string, SearchResult[]>();
typedItems.forEach((item) => {
const preliminaryKey = `${normalizeTitle(item.title).toLowerCase()}-${getType(item)}`;
const group = preliminaryMap.get(preliminaryKey) || [];
group.push(item);
preliminaryMap.set(preliminaryKey, group);
});
const finalRecommendations: PlayFallbackRecommendation[] = [];
preliminaryMap.forEach((group, preliminaryKey) => {
const withYear = new Map<string, SearchResult[]>();
const withoutYear: SearchResult[] = [];
group.forEach((item) => {
if (item.year && item.year.trim() !== '' && item.year !== 'unknown' && /^\d{4}$/.test(item.year)) {
const yearGroup = withYear.get(item.year) || [];
yearGroup.push(item);
withYear.set(item.year, yearGroup);
} else {
withoutYear.push(item);
}
});
const emitGroup = (groupKey: string, mergedGroup: SearchResult[]) => {
const sourceNames = Array.from(new Set(mergedGroup.map((item) => item.source_name).filter(Boolean)));
const episodeCountMap = new Map<number, number>();
const doubanCountMap = new Map<number, number>();
mergedGroup.forEach((item) => {
const episodeCount = item.episodes?.length || 0;
if (episodeCount > 0) {
episodeCountMap.set(episodeCount, (episodeCountMap.get(episodeCount) || 0) + 1);
}
if (item.douban_id && item.douban_id > 0) {
doubanCountMap.set(item.douban_id, (doubanCountMap.get(item.douban_id) || 0) + 1);
}
});
let episodes = 0;
let episodeVotes = 0;
episodeCountMap.forEach((votes, count) => {
if (votes > episodeVotes) {
episodeVotes = votes;
episodes = count;
}
});
let doubanId: number | undefined;
let doubanVotes = 0;
doubanCountMap.forEach((votes, id) => {
if (votes > doubanVotes) {
doubanVotes = votes;
doubanId = id;
}
});
const representative = mergedGroup.slice().sort((a, b) => {
const aPoster = a.poster ? 1 : 0;
const bPoster = b.poster ? 1 : 0;
if (bPoster !== aPoster) return bPoster - aPoster;
return (b.weight ?? 0) - (a.weight ?? 0);
})[0];
finalRecommendations.push({
key: groupKey,
item: representative,
episodes: episodes || undefined,
sourceNames,
doubanId,
});
};
if (withYear.size > 0) {
withYear.forEach((yearGroup, year) => {
emitGroup(`${preliminaryKey}-${year}`, [...yearGroup, ...withoutYear]);
});
} else if (withoutYear.length > 0) {
emitGroup(`${preliminaryKey}-unknown`, withoutYear);
}
});
const normalizedQuery = normalizeTitle(query).toLowerCase();
return finalRecommendations
.sort((a, b) => {
const aContains = normalizeTitle(a.item.title).toLowerCase().includes(normalizedQuery) ? 1 : 0;
const bContains = normalizeTitle(b.item.title).toLowerCase().includes(normalizedQuery) ? 1 : 0;
if (bContains !== aContains) return bContains - aContains;
if (b.sourceNames.length !== a.sourceNames.length) return b.sourceNames.length - a.sourceNames.length;
return (b.item.weight ?? 0) - (a.item.weight ?? 0);
})
.slice(0, 12);
};
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
// 根据搜索词获取全部源信息
setHasCompletedSearchRequest(false);
setFallbackRecommendations([]);
try {
// 先检查 sessionStorage 中是否有缓存
const cacheKey = `search_cache_${query.trim()}`;
@@ -3426,7 +3538,10 @@ function PlayPageClient() {
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
console.log('[Play] 使用 sessionStorage 缓存的搜索结果');
const cachedData = JSON.parse(cached);
const cachedData = JSON.parse(cached) as SearchResult[];
setHasCompletedSearchRequest(true);
setFallbackRecommendations(buildFallbackRecommendations(cachedData, query));
// 处理缓存的搜索结果,根据规则过滤
results = cachedData.filter(
@@ -3462,9 +3577,13 @@ function PlayPageClient() {
throw new Error('搜索失败');
}
const data = await response.json();
const allResults = (data.results || []) as SearchResult[];
setHasCompletedSearchRequest(true);
setFallbackRecommendations(buildFallbackRecommendations(allResults, query));
// 处理搜索结果,根据规则过滤
results = data.results.filter(
results = allResults.filter(
(result: SearchResult) =>
normalizeTitle(result.title).toLowerCase() ===
normalizeTitle(videoTitleRef.current).toLowerCase() &&
@@ -7918,71 +8037,107 @@ function PlayPageClient() {
if (error) {
return (
<PageLayout activePath='/play' hideNavigation={isWebFullscreen}>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>😵</div>
{/* 脉冲效果 */}
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
<div className='flex min-h-screen w-full items-center justify-center overflow-x-hidden bg-transparent px-4 py-6'>
<div className='flex w-full flex-col items-center'>
<div className='w-full max-w-md text-center'>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto flex h-24 w-24 items-center justify-center rounded-2xl bg-gradient-to-r from-red-500 to-orange-500 shadow-2xl transition-transform duration-300 hover:scale-105'>
<div className='text-4xl text-white'>😵</div>
{/* 脉冲效果 */}
<div className='absolute -inset-2 animate-pulse rounded-2xl bg-gradient-to-r from-red-500 to-orange-500 opacity-20'></div>
</div>
{/* 浮动错误粒子 */}
<div className='pointer-events-none absolute left-0 top-0 h-full w-full'>
<div className='absolute left-2 top-2 h-2 w-2 animate-bounce rounded-full bg-red-400'></div>
<div
className='absolute right-4 top-4 h-1.5 w-1.5 animate-bounce rounded-full bg-orange-400'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 h-1 w-1 animate-bounce rounded-full bg-yellow-400'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 浮动错误粒子 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-red-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-orange-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-yellow-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 错误信息 */}
<div className='space-y-4 mb-8'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
<p className='text-red-600 dark:text-red-400 font-medium'>
{error}
{/* 错误信息 */}
<div className='mb-8 space-y-4'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<div className='rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20'>
<p className='font-medium text-red-600 dark:text-red-400'>
{error}
</p>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400'>
</p>
{/* 操作按钮 */}
<div className='space-y-3'>
<button
onClick={() =>
videoTitle
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
: router.back()
}
className='w-full rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-3 font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:from-green-600 hover:to-emerald-700 hover:shadow-xl'
>
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
</button>
<button
onClick={() => window.location.reload()}
className='w-full rounded-xl bg-gray-100 px-6 py-3 font-medium text-gray-700 transition-colors duration-200 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
>
🔄
</button>
</div>
</div>
{/* 操作按钮 */}
<div className='space-y-3'>
<button
onClick={() =>
videoTitle
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
: router.back()
}
className='w-full px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-xl font-medium hover:from-green-600 hover:to-emerald-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
>
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
</button>
<button
onClick={() => window.location.reload()}
className='w-full px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200'
>
🔄
</button>
</div>
{hasCompletedSearchRequest && fallbackRecommendations.length > 0 && (
<div className='mt-4 w-full max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-gray-200 bg-white/70 p-3 text-left dark:border-gray-700 dark:bg-gray-800/70 sm:max-w-3xl lg:max-w-5xl'>
<div className='mb-3 flex items-center gap-2'>
<Sparkles className='h-4 w-4 flex-shrink-0 text-amber-500' />
<h3 className='text-sm font-semibold text-gray-800 dark:text-gray-200'>
</h3>
</div>
<div className='w-full overflow-x-auto overflow-y-hidden pb-1'>
<div className='inline-flex gap-2.5 sm:gap-3'>
{fallbackRecommendations.map((recommendation) => (
<div
key={recommendation.key}
className='w-[118px] min-w-[118px] flex-shrink-0 sm:w-[150px] sm:min-w-[150px]'
>
<VideoCard
title={recommendation.item.title}
query={searchTitle || videoTitle}
poster={recommendation.item.poster}
episodes={recommendation.episodes}
source_names={recommendation.sourceNames}
year={recommendation.item.year}
douban_id={recommendation.doubanId}
from='search'
isAggregate
/>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
</PageLayout>
);
}
return (
<PageLayout activePath='/play' hideNavigation={isWebFullscreen}>
{/* TMDB背景图 */}