完善tv search页面
This commit is contained in:
+184
-10
@@ -1,38 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { Film, Loader2, Search } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { addSearchHistory, getSearchHistory } from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import TVLayout from '@/components/tv/TVLayout';
|
||||
|
||||
const hot = ['庆余年', '流浪地球', '繁花', '甄嬛传', '鬼灭之刃', '歌手', '三体', '权力的游戏'];
|
||||
|
||||
function getSearchCacheKey(query: string) {
|
||||
return `search_cache_${query.trim()}`;
|
||||
}
|
||||
|
||||
function setCachedSearchResults(query: string, nextResults: SearchResult[]) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
getSearchCacheKey(query),
|
||||
JSON.stringify({
|
||||
status: 'complete',
|
||||
results: nextResults,
|
||||
query: query.trim(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
type TVSearchDisplayItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
poster?: string;
|
||||
year?: string;
|
||||
vodRemarks?: string;
|
||||
sourceName?: string;
|
||||
sourceNames: string[];
|
||||
source?: string;
|
||||
id?: string;
|
||||
isAggregate: boolean;
|
||||
};
|
||||
|
||||
function normalizeTitle(title: string) {
|
||||
return title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[第\s._\-::]+/g, '')
|
||||
.replace(/[((].*?[))]/g, '');
|
||||
}
|
||||
|
||||
function getResultType(item: SearchResult) {
|
||||
const text = `${item.type_name || ''} ${item.class || ''}`.toLowerCase();
|
||||
if (text.includes('电影') || text.includes('movie')) return 'movie';
|
||||
return 'tv';
|
||||
}
|
||||
|
||||
function getValidYear(item: SearchResult) {
|
||||
return item.year && /^\d{4}$/.test(item.year) ? item.year : 'unknown';
|
||||
}
|
||||
|
||||
function getTVDetailUrl(item: TVSearchDisplayItem) {
|
||||
const params = new URLSearchParams({
|
||||
title: item.title,
|
||||
});
|
||||
if (!item.isAggregate && item.source) params.set('source', item.source);
|
||||
if (!item.isAggregate && item.id) params.set('id', item.id);
|
||||
return `/tv/detail?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default function TVSearchPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const firstResultRef = useRef<HTMLButtonElement | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
getSearchHistory().then(setHistory).catch(() => setHistory([]));
|
||||
}, []);
|
||||
|
||||
const runSearch = (value: string) => {
|
||||
const q = value.trim();
|
||||
if (!q) return;
|
||||
setKeyword(q);
|
||||
setSearched(q);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResults([]);
|
||||
addSearchHistory(q).catch(() => undefined);
|
||||
fetch(`/api/search?q=${encodeURIComponent(q)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error('搜索失败');
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const nextResults = Array.isArray(data.results) ? data.results : [];
|
||||
setResults(nextResults);
|
||||
setCachedSearchResults(q, nextResults);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : '搜索失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const submit = (event?: FormEvent) => {
|
||||
event?.preventDefault();
|
||||
const q = keyword.trim();
|
||||
if (q) {
|
||||
addSearchHistory(q).catch(() => undefined);
|
||||
router.push(`/tv/play?title=${encodeURIComponent(q)}`);
|
||||
}
|
||||
runSearch(keyword);
|
||||
};
|
||||
|
||||
const displayResults = useMemo<TVSearchDisplayItem[]>(() => {
|
||||
const groups = new Map<string, SearchResult[]>();
|
||||
const order: string[] = [];
|
||||
|
||||
results.forEach((item) => {
|
||||
const key = `${normalizeTitle(item.title)}-${getResultType(item)}-${getValidYear(item)}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
order.push(key);
|
||||
}
|
||||
groups.get(key)?.push(item);
|
||||
});
|
||||
|
||||
return order.map((key) => {
|
||||
const group = groups.get(key) || [];
|
||||
const first = group[0];
|
||||
const sourceNames = Array.from(new Set(group.map((item) => item.source_name || item.source).filter(Boolean)));
|
||||
const bestPoster = group.find((item) => item.poster)?.poster || first?.poster || '';
|
||||
const bestYear = group.find((item) => getValidYear(item) !== 'unknown')?.year || first?.year || '';
|
||||
const bestRemarks = group.find((item) => item.vod_remarks)?.vod_remarks || first?.vod_remarks || '';
|
||||
|
||||
return {
|
||||
key,
|
||||
title: first?.title || '',
|
||||
poster: bestPoster,
|
||||
year: bestYear,
|
||||
vodRemarks: bestRemarks,
|
||||
sourceName: first?.source_name || first?.source || '',
|
||||
sourceNames,
|
||||
source: first?.source,
|
||||
id: first?.id,
|
||||
isAggregate: group.length > 1,
|
||||
};
|
||||
});
|
||||
}, [results]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || error || displayResults.length === 0) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
firstResultRef.current?.focus({ preventScroll: true });
|
||||
firstResultRef.current?.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
});
|
||||
}, [displayResults.length, error, loading]);
|
||||
|
||||
return (
|
||||
<TVLayout>
|
||||
<div data-tv-focus-scope='active'>
|
||||
<section className='mx-auto max-w-6xl rounded-[42px] border border-white/10 bg-slate-950/70 p-10 shadow-2xl shadow-black/60'>
|
||||
<h1 className='text-6xl font-black'>搜索</h1>
|
||||
<p className='mt-4 text-2xl text-slate-300'>输入片名后直接进入 TV 全屏播放页,后续可接入屏幕键盘。</p>
|
||||
<p className='mt-4 text-2xl text-slate-300'>输入片名后查看搜索结果,选择影片进入详情页后播放。</p>
|
||||
<form onSubmit={submit} className='mt-10 flex gap-4'>
|
||||
<label className='sr-only' htmlFor='tv-search'>搜索片名</label>
|
||||
<input
|
||||
@@ -48,12 +181,52 @@ export default function TVSearchPage() {
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{(loading || searched || error) && (
|
||||
<section className='mx-auto mt-12 max-w-6xl'>
|
||||
<div className='mb-6 flex items-center justify-between gap-4'>
|
||||
<h2 className='text-4xl font-black'>{searched ? `“${searched}” 的搜索结果` : '搜索结果'}</h2>
|
||||
{loading && <div className='flex items-center gap-3 text-2xl font-bold text-slate-300'><Loader2 className='h-7 w-7 animate-spin text-rose-500' />搜索中...</div>}
|
||||
</div>
|
||||
{error ? (
|
||||
<div className='rounded-3xl border border-red-500/40 bg-red-950/40 p-8 text-2xl font-bold text-red-100'>{error}</div>
|
||||
) : loading ? null : displayResults.length === 0 ? (
|
||||
<div className='rounded-3xl border border-white/10 bg-white/[0.06] p-8 text-2xl font-bold text-slate-300'>未找到相关结果</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-2 gap-5 lg:grid-cols-4'>
|
||||
{displayResults.map((item, index) => (
|
||||
<button
|
||||
key={item.key}
|
||||
ref={index === 0 ? firstResultRef : undefined}
|
||||
onClick={() => router.push(getTVDetailUrl(item))}
|
||||
className='tv-focusable cursor-pointer overflow-hidden rounded-3xl border border-white/10 bg-white/[0.06] text-left outline-none transition hover:bg-white/12 focus:ring-4 focus:ring-rose-300'
|
||||
>
|
||||
<div className='aspect-[2/3] bg-slate-900'>
|
||||
{item.poster ? (
|
||||
<img src={processImageUrl(item.poster)} alt='' className='h-full w-full object-cover' />
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center'><Film className='h-16 w-16 text-slate-600' /></div>
|
||||
)}
|
||||
</div>
|
||||
<div className='p-5'>
|
||||
<div className='line-clamp-1 text-2xl font-black text-white'>{item.title}</div>
|
||||
<div className='mt-2 line-clamp-1 text-lg font-bold text-slate-300'>{item.isAggregate ? `${item.sourceNames.length} 个播放源` : item.sourceName}</div>
|
||||
<div className='mt-2 flex flex-wrap gap-2 text-base font-bold text-slate-400'>
|
||||
{item.year && <span>{item.year}</span>}
|
||||
{item.vodRemarks && <span>{item.vodRemarks}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{history.length > 0 && (
|
||||
<section className='mx-auto mt-12 max-w-6xl'>
|
||||
<h2 className='text-4xl font-black'>搜索历史</h2>
|
||||
<div className='mt-6 grid grid-cols-2 gap-4 md:grid-cols-4'>
|
||||
{history.slice(0, 20).map((item) => (
|
||||
<button key={item} onClick={() => router.push(`/tv/play?title=${encodeURIComponent(item)}`)} className='cursor-pointer rounded-3xl border border-white/10 bg-white/[0.06] px-6 py-5 text-2xl font-bold text-white outline-none transition hover:bg-white/12 tv-focusable'>
|
||||
<button key={item} onClick={() => runSearch(item)} className='cursor-pointer rounded-3xl border border-white/10 bg-white/[0.06] px-6 py-5 text-2xl font-bold text-white outline-none transition hover:bg-white/12 tv-focusable'>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
@@ -64,12 +237,13 @@ export default function TVSearchPage() {
|
||||
<h2 className='text-4xl font-black'>热门搜索</h2>
|
||||
<div className='mt-6 grid grid-cols-2 gap-4 md:grid-cols-4'>
|
||||
{hot.map((item) => (
|
||||
<button key={item} onClick={() => router.push(`/tv/play?title=${encodeURIComponent(item)}`)} className='cursor-pointer rounded-3xl border border-white/10 bg-white/[0.06] px-6 py-5 text-2xl font-bold text-white outline-none transition hover:bg-white/12 tv-focusable'>
|
||||
<button key={item} onClick={() => runSearch(item)} className='cursor-pointer rounded-3xl border border-white/10 bg-white/[0.06] px-6 py-5 text-2xl font-bold text-white outline-none transition hover:bg-white/12 tv-focusable'>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</TVLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
type SearchCachePayload = {
|
||||
status: 'complete' | 'partial';
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function getCachedSearchResults(query?: string | null) {
|
||||
const keyword = query?.trim();
|
||||
if (!keyword || typeof window === 'undefined') return null;
|
||||
try {
|
||||
const cached = sessionStorage.getItem(`search_cache_${keyword}`);
|
||||
if (!cached) return null;
|
||||
const parsed = JSON.parse(cached) as SearchCachePayload;
|
||||
if (Array.isArray(parsed.results) && parsed.results.length > 0) return parsed.results;
|
||||
} catch {
|
||||
// ignore invalid cache
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchSearchResults(query: string) {
|
||||
const cached = getCachedSearchResults(query);
|
||||
if (cached) return cached;
|
||||
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('搜索播放源失败');
|
||||
const data = await res.json();
|
||||
return (data.results || []) as SearchResult[];
|
||||
}
|
||||
|
||||
export async function fetchTVDetail(params: {
|
||||
source?: string | null;
|
||||
id?: string | null;
|
||||
@@ -18,15 +49,11 @@ export async function fetchTVDetail(params: {
|
||||
const searchTitle = title || detail.title;
|
||||
if (searchTitle) {
|
||||
try {
|
||||
const searchRes = await fetch(`/api/search?q=${encodeURIComponent(searchTitle)}`, { cache: 'no-store' });
|
||||
if (searchRes.ok) {
|
||||
const data = await searchRes.json();
|
||||
const list = (data.results || []) as SearchResult[];
|
||||
sources = [
|
||||
detail,
|
||||
...list.filter((item) => !(item.source === detail.source && item.id === detail.id)),
|
||||
];
|
||||
}
|
||||
const list = await fetchSearchResults(searchTitle);
|
||||
sources = [
|
||||
detail,
|
||||
...list.filter((item) => !(item.source === detail.source && item.id === detail.id)),
|
||||
];
|
||||
} catch {
|
||||
// 换源搜索失败不影响当前播放
|
||||
}
|
||||
@@ -35,10 +62,7 @@ export async function fetchTVDetail(params: {
|
||||
}
|
||||
|
||||
if (!title) throw new Error('缺少片名');
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(title)}`, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('搜索播放源失败');
|
||||
const data = await res.json();
|
||||
const sources = (data.results || []) as SearchResult[];
|
||||
const sources = await fetchSearchResults(title);
|
||||
if (sources.length === 0) throw new Error('未找到播放源');
|
||||
let detail = sources[0];
|
||||
if (!detail.episodes?.length) {
|
||||
|
||||
Reference in New Issue
Block a user