diff --git a/src/app/api/tmdb/videos/route.ts b/src/app/api/tmdb/videos/route.ts
new file mode 100644
index 0000000..4f552e6
--- /dev/null
+++ b/src/app/api/tmdb/videos/route.ts
@@ -0,0 +1,117 @@
+/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
+
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { getTMDBVideoList, searchTMDBMulti } from '@/lib/tmdb.client';
+
+export const runtime = 'nodejs';
+
+function normalizeType(type: string | null): 'movie' | 'tv' | null {
+ if (type === 'movie' || type === 'tv') return type;
+ return null;
+}
+
+/**
+ * GET /api/tmdb/videos?id=xxx&type=movie|tv
+ * GET /api/tmdb/videos?title=xxx&type=movie|tv&year=2026
+ * 获取 TMDB YouTube 视频列表,用于选择预告片
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: '未授权' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const idParam = searchParams.get('id');
+ const title = searchParams.get('title') || '';
+ const typeParam = normalizeType(searchParams.get('type')) || 'movie';
+ const year = searchParams.get('year') || '';
+
+ const config = await getConfig();
+ const tmdbApiKey = config.SiteConfig.TMDBApiKey;
+ const tmdbProxy = config.SiteConfig.TMDBProxy;
+ const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy;
+
+ if (!tmdbApiKey) {
+ return NextResponse.json(
+ { error: 'TMDB API Key 未配置' },
+ { status: 400 }
+ );
+ }
+
+ let mediaId = idParam ? parseInt(idParam, 10) : 0;
+ let mediaType: 'movie' | 'tv' = typeParam;
+
+ if (!mediaId) {
+ if (!title.trim()) {
+ return NextResponse.json({ error: '缺少 id 或 title 参数' }, { status: 400 });
+ }
+
+ const searchResponse = await searchTMDBMulti(
+ tmdbApiKey,
+ title.trim(),
+ tmdbProxy,
+ tmdbReverseProxy
+ );
+
+ if (searchResponse.code !== 200) {
+ return NextResponse.json(
+ { error: 'TMDB 搜索失败', code: searchResponse.code },
+ { status: searchResponse.code }
+ );
+ }
+
+ const validResults = (searchResponse.results || []).filter(
+ (item: any) => item.media_type === 'movie' || item.media_type === 'tv'
+ );
+ const matched =
+ validResults.find((item: any) => {
+ if (item.media_type !== mediaType) return false;
+ if (!year) return true;
+ const date = item.release_date || item.first_air_date || '';
+ return date.startsWith(year);
+ }) ||
+ validResults.find((item: any) => item.media_type === mediaType) ||
+ validResults[0];
+
+ if (!matched?.id) {
+ return NextResponse.json({ error: '未找到 TMDB 条目' }, { status: 404 });
+ }
+
+ mediaId = matched.id;
+ mediaType = matched.media_type;
+ }
+
+ const response = await getTMDBVideoList(
+ tmdbApiKey,
+ mediaType,
+ mediaId,
+ tmdbProxy,
+ tmdbReverseProxy
+ );
+
+ if (response.code !== 200) {
+ return NextResponse.json(
+ { error: 'TMDB 视频获取失败', code: response.code },
+ { status: response.code }
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ mediaId,
+ mediaType,
+ videos: response.videos,
+ });
+ } catch (error) {
+ console.error('TMDB视频获取失败:', error);
+ return NextResponse.json(
+ { error: '获取预告片失败', details: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index c67c9b6..cb8668b 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -852,6 +852,7 @@ function HomeClient() {
}
type={item.media_type === 'tv' ? 'tv' : 'movie'}
from='douban'
+ tmdb_id={item.id}
releaseDate={item.release_date}
isUpcoming={true}
/>
diff --git a/src/components/MobileActionSheet.tsx b/src/components/MobileActionSheet.tsx
index 9216116..6127b1d 100644
--- a/src/components/MobileActionSheet.tsx
+++ b/src/components/MobileActionSheet.tsx
@@ -26,6 +26,7 @@ interface MobileActionSheetProps {
totalEpisodes?: number; // 总集数
origin?: 'vod' | 'live';
onPosterClick?: () => void; // 海报点击回调
+ description?: string; // 标题下方描述文案
}
const MobileActionSheet: React.FC
- 选择操作 + {description}
diff --git a/src/components/TrailerPickerDialog.tsx b/src/components/TrailerPickerDialog.tsx new file mode 100644 index 0000000..ddf8bde --- /dev/null +++ b/src/components/TrailerPickerDialog.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { ExternalLink, Film, Loader2, PlayCircle, Youtube, X } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import type { TMDBVideoItem } from '@/lib/tmdb.client'; + +interface TrailerPickerDialogProps { + isOpen: boolean; + title: string; + videos: TMDBVideoItem[]; + loading?: boolean; + error?: string | null; + onClose: () => void; + onRetry?: () => void; + onSelect: (video: TMDBVideoItem) => void; +} + +export default function TrailerPickerDialog({ + isOpen, + title, + videos, + loading = false, + error = null, + onClose, + onRetry, + onSelect, +}: TrailerPickerDialogProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!isOpen) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isOpen, onClose]); + + const sortedVideos = useMemo(() => { + return [...videos].sort((a, b) => { + const aOfficial = a.official ? 1 : 0; + const bOfficial = b.official ? 1 : 0; + if (aOfficial !== bOfficial) return bOfficial - aOfficial; + const aTrailer = a.type === 'Trailer' ? 1 : 0; + const bTrailer = b.type === 'Trailer' ? 1 : 0; + if (aTrailer !== bTrailer) return bTrailer - aTrailer; + return (b.published_at || '').localeCompare(a.published_at || ''); + }); + }, [videos]); + + if (!mounted || !isOpen) return null; + + return createPortal( ++ 该影片在 TMDB 中没有可用的 YouTube 视频。 +
+