From c961d0999c82d22e1e015330b5fb0a7d54acdc91 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 27 Dec 2025 11:37:43 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=A2=9E=E5=8A=A0tmdb?= =?UTF-8?q?=E7=83=AD=E9=97=A8=E8=BD=AE=E6=92=AD=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/tmdb/trending/route.ts | 47 +++++ src/app/page.tsx | 10 +- src/components/BannerCarousel.tsx | 296 +++++++++++++++++++++++++++++ src/lib/tmdb.client.ts | 114 +++++++++++ 4 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 src/app/api/tmdb/trending/route.ts create mode 100644 src/components/BannerCarousel.tsx diff --git a/src/app/api/tmdb/trending/route.ts b/src/app/api/tmdb/trending/route.ts new file mode 100644 index 0000000..6005948 --- /dev/null +++ b/src/app/api/tmdb/trending/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import { getTMDBTrendingContent } from '@/lib/tmdb.client'; +import { getConfig } from '@/lib/config'; + +// 缓存配置 - 服务器内存缓存3小时 +const CACHE_DURATION = 3 * 60 * 60 * 1000; // 3小时 +let cachedData: { data: any; timestamp: number } | null = null; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + // 检查缓存 + if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) { + return NextResponse.json(cachedData.data); + } + + // 获取配置 + const config = await getConfig(); + const apiKey = config.SiteConfig?.TMDBApiKey; + const proxy = config.SiteConfig?.TMDBProxy; + + if (!apiKey) { + return NextResponse.json( + { code: 400, message: 'TMDB API Key 未配置' }, + { status: 400 } + ); + } + + // 获取热门内容 + const result = await getTMDBTrendingContent(apiKey, proxy); + + // 更新缓存 + cachedData = { + data: result, + timestamp: Date.now(), + }; + + return NextResponse.json(result); + } catch (error) { + console.error('获取 TMDB 热门内容失败:', error); + return NextResponse.json( + { code: 500, message: '获取热门内容失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index e29119d..72187ca 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -28,6 +28,7 @@ import ScrollableRow from '@/components/ScrollableRow'; import { useSite } from '@/components/SiteProvider'; import VideoCard from '@/components/VideoCard'; import HttpWarningDialog from '@/components/HttpWarningDialog'; +import BannerCarousel from '@/components/BannerCarousel'; function HomeClient() { const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home'); @@ -222,8 +223,15 @@ function HomeClient() { return ( + {/* TMDB 热门轮播图 - 只在首页显示,且占满宽度 */} + {activeTab === 'home' && ( +
+ +
+ )} +
- {/* 顶部 Tab 切换 */} + {/* Tab 切换移到轮播图下方 */}
([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [isPaused, setIsPaused] = useState(false); + const [skipNextAutoPlay, setSkipNextAutoPlay] = useState(false); // 跳过下一次自动播放 + const touchStartX = useRef(0); + const touchEndX = useRef(0); + const isManualChange = useRef(false); // 标记是否为手动切换 + + // LocalStorage 缓存配置 + const LOCALSTORAGE_KEY = 'tmdb_trending_cache'; + const LOCALSTORAGE_DURATION = 24 * 60 * 60 * 1000; // 1天 + + // 跳转到播放页面 + const handlePlay = (title: string) => { + router.push(`/play?title=${encodeURIComponent(title)}`); + }; + + // 获取热门内容 + useEffect(() => { + const fetchTrending = async () => { + try { + // 先检查 localStorage 缓存 + const cached = localStorage.getItem(LOCALSTORAGE_KEY); + if (cached) { + try { + const { data, timestamp } = JSON.parse(cached); + const now = Date.now(); + + // 如果缓存未过期,直接使用 + if (now - timestamp < LOCALSTORAGE_DURATION) { + setItems(data); + setIsLoading(false); + return; + } + } catch (e) { + // 缓存解析失败,继续请求 API + console.error('解析缓存数据失败:', e); + } + } + + // 从 API 获取数据 + const response = await fetch('/api/tmdb/trending'); + const result = await response.json(); + if (result.code === 200 && result.list.length > 0) { + setItems(result.list); + + // 保存到 localStorage + try { + localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify({ + data: result.list, + timestamp: Date.now() + })); + } catch (e) { + // localStorage 可能已满,忽略错误 + console.error('保存到 localStorage 失败:', e); + } + } + } catch (error) { + console.error('获取热门内容失败:', error); + } finally { + setIsLoading(false); + } + }; + + fetchTrending(); + }, [LOCALSTORAGE_KEY, LOCALSTORAGE_DURATION]); + + // 自动播放 + useEffect(() => { + if (!items.length || isPaused) return; + + const timer = setInterval(() => { + // 如果设置了跳过标志,跳过这一次自动播放 + if (skipNextAutoPlay) { + setSkipNextAutoPlay(false); + return; + } + + setCurrentIndex((prev) => (prev + 1) % items.length); + }, autoPlayInterval); + + return () => clearInterval(timer); + }, [items.length, isPaused, autoPlayInterval, skipNextAutoPlay]); + + const goToPrevious = useCallback(() => { + isManualChange.current = true; + setSkipNextAutoPlay(true); + setCurrentIndex((prev) => (prev - 1 + items.length) % items.length); + setTimeout(() => { + isManualChange.current = false; + }, 100); + }, [items.length]); + + const goToNext = useCallback(() => { + isManualChange.current = true; + setSkipNextAutoPlay(true); + setCurrentIndex((prev) => (prev + 1) % items.length); + setTimeout(() => { + isManualChange.current = false; + }, 100); + }, [items.length]); + + const goToSlide = useCallback((index: number) => { + isManualChange.current = true; + setSkipNextAutoPlay(true); + setCurrentIndex(index); + setTimeout(() => { + isManualChange.current = false; + }, 100); + }, []); + + // 触摸事件处理 + const handleTouchStart = (e: React.TouchEvent) => { + // 防止在手动切换过程中触发 + if (isManualChange.current) return; + touchStartX.current = e.touches[0].clientX; + touchEndX.current = 0; // 重置结束位置 + }; + + const handleTouchMove = (e: React.TouchEvent) => { + // 防止在手动切换过程中触发 + if (isManualChange.current) return; + touchEndX.current = e.touches[0].clientX; + }; + + const handleTouchEnd = () => { + // 防止在手动切换过程中触发 + if (isManualChange.current) return; + if (!touchStartX.current) return; + + // 如果有滑动,则执行滑动逻辑 + if (touchEndX.current !== 0) { + const distance = touchStartX.current - touchEndX.current; + const minSwipeDistance = 50; // 最小滑动距离 + + if (Math.abs(distance) > minSwipeDistance) { + if (distance > 0) { + // 向左滑动,显示下一张 + goToNext(); + } else { + // 向右滑动,显示上一张 + goToPrevious(); + } + } + } + + // 重置 + touchStartX.current = 0; + touchEndX.current = 0; + }; + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + if (!items.length) { + return null; + } + + const currentItem = items[currentIndex]; + + return ( +
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onClick={() => { + // 移动端点击整个轮播图跳转 + if (window.innerWidth < 768) { + handlePlay(currentItem.title); + } + }} + > + {/* 背景图片 */} +
+ {items.map((item, index) => ( +
+ {item.title} + {/* 渐变遮罩 */} +
+
+
+ ))} +
+ + {/* 内容信息 */} +
+
+

+ {currentItem.title} +

+ +
+ + {currentItem.vote_average.toFixed(1)} + + {getGenreNames(currentItem.genre_ids, 3).map(genre => ( + + {genre} + + ))} + {currentItem.release_date && ( + {currentItem.release_date} + )} +
+ + {/* PC端播放按钮 */} + + + {currentItem.overview && ( +

+ {currentItem.overview} +

+ )} +
+
+ + {/* 左右切换按钮 - 只在桌面端显示 */} + + + + {/* 指示器 */} +
+ {items.map((_, index) => ( +
+
+ ); +} diff --git a/src/lib/tmdb.client.ts b/src/lib/tmdb.client.ts index d5f4ff6..02ab88d 100644 --- a/src/lib/tmdb.client.ts +++ b/src/lib/tmdb.client.ts @@ -26,10 +26,12 @@ export interface TMDBItem { id: number; title: string; poster_path: string | null; + backdrop_path?: string | null; // 背景图,用于轮播图 release_date: string; overview: string; vote_average: number; media_type: 'movie' | 'tv'; + genre_ids?: number[]; // 类型ID列表 } interface TMDBUpcomingResponse { @@ -229,6 +231,70 @@ export async function getTMDBUpcomingContent( } } +/** + * 获取热门内容(电影+电视剧) + * @param apiKey - TMDB API Key + * @param proxy - 代理服务器地址 + * @returns 热门内容列表 + */ +export async function getTMDBTrendingContent( + apiKey: string, + proxy?: string +): Promise<{ code: number; list: TMDBItem[] }> { + try { + if (!apiKey) { + return { code: 400, list: [] }; + } + + // 获取本周热门内容(电影+电视剧) + const url = `https://api.themoviedb.org/3/trending/all/week?api_key=${apiKey}&language=zh-CN`; + const fetchOptions: any = proxy + ? { + agent: new HttpsProxyAgent(proxy, { + timeout: 30000, + keepAlive: false, + }), + signal: AbortSignal.timeout(30000), + } + : { + signal: AbortSignal.timeout(15000), + }; + + const response = await nodeFetch(url, fetchOptions); + + if (!response.ok) { + console.error('TMDB Trending API 请求失败:', response.status, response.statusText); + return { code: response.status, list: [] }; + } + + const data: any = await response.json(); + + // 转换为统一格式,只保留有backdrop_path的项目(用于轮播图) + const items: TMDBItem[] = data.results + .filter((item: any) => item.backdrop_path) // 只保留有背景图的 + .slice(0, 10) // 只取前10个 + .map((item: any) => ({ + id: item.id, + title: item.title || item.name, + poster_path: item.poster_path, + backdrop_path: item.backdrop_path, // 添加背景图 + release_date: item.release_date || item.first_air_date || '', + overview: item.overview, + vote_average: item.vote_average, + media_type: item.media_type as 'movie' | 'tv', + genre_ids: item.genre_ids || [], // 保存类型ID + })); + + return { + code: 200, + list: items, + }; + } catch (error) { + console.error('获取 TMDB 热门内容失败:', error); + return { code: 500, list: [] }; + } +} + /** * 获取 TMDB 图片完整 URL * @param path - 图片路径 @@ -242,3 +308,51 @@ export function getTMDBImageUrl( if (!path) return ''; return `https://image.tmdb.org/t/p/${size}${path}`; } + +/** + * TMDB 类型映射(中文) + */ +export const TMDB_GENRES: Record = { + // 电影类型 + 28: '动作', + 12: '冒险', + 16: '动画', + 35: '喜剧', + 80: '犯罪', + 99: '纪录', + 18: '剧情', + 10751: '家庭', + 14: '奇幻', + 36: '历史', + 27: '恐怖', + 10402: '音乐', + 9648: '悬疑', + 10749: '爱情', + 878: '科幻', + 10770: '电视电影', + 53: '惊悚', + 10752: '战争', + 37: '西部', + // 电视剧类型 + 10759: '动作冒险', + 10762: '儿童', + 10763: '新闻', + 10764: '真人秀', + 10765: '科幻奇幻', + 10766: '肥皂剧', + 10767: '脱口秀', + 10768: '战争政治', +}; + +/** + * 根据类型ID获取类型名称列表 + * @param genreIds - 类型ID数组 + * @param limit - 最多返回几个类型,默认2个 + * @returns 类型名称数组 + */ +export function getGenreNames(genreIds: number[] = [], limit: number = 2): string[] { + return genreIds + .map(id => TMDB_GENRES[id]) + .filter(Boolean) + .slice(0, limit); +}