From 56d0de288fa76532a5e82da9ebd9b8aad3f14a5d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 30 May 2026 21:13:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84tv=20search=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/tv/search/page.tsx | 194 ++++++++++++++++++++++++++++-- src/components/tv/player/utils.ts | 50 ++++++-- 2 files changed, 221 insertions(+), 23 deletions(-) diff --git a/src/app/tv/search/page.tsx b/src/app/tv/search/page.tsx index a580386..ae0fa38 100644 --- a/src/app/tv/search/page.tsx +++ b/src/app/tv/search/page.tsx @@ -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([]); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(''); + const [error, setError] = useState(''); + const firstResultRef = useRef(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(() => { + const groups = new Map(); + 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 ( +

搜索

-

输入片名后直接进入 TV 全屏播放页,后续可接入屏幕键盘。

+

输入片名后查看搜索结果,选择影片进入详情页后播放。

+ {(loading || searched || error) && ( +
+
+

{searched ? `“${searched}” 的搜索结果` : '搜索结果'}

+ {loading &&
搜索中...
} +
+ {error ? ( +
{error}
+ ) : loading ? null : displayResults.length === 0 ? ( +
未找到相关结果
+ ) : ( +
+ {displayResults.map((item, index) => ( + + ))} +
+ )} +
+ )} {history.length > 0 && (

搜索历史

{history.slice(0, 20).map((item) => ( - ))} @@ -64,12 +237,13 @@ export default function TVSearchPage() {

热门搜索

{hot.map((item) => ( - ))}
+
); } diff --git a/src/components/tv/player/utils.ts b/src/components/tv/player/utils.ts index 5d63d54..3aa703a 100644 --- a/src/components/tv/player/utils.ts +++ b/src/components/tv/player/utils.ts @@ -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) {