From 16e48bef8b6ce21058a7bddbddc18b9fe108171d Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Tue, 2 Jun 2026 10:34:13 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=B3=E5=B0=86=E4=B8=8A?=
=?UTF-8?q?=E6=98=A0=E9=9C=80=E5=A4=9A=E6=AC=A1=E7=82=B9=E5=87=BB=E6=89=8D?=
=?UTF-8?q?=E6=98=BE=E7=A4=BA=EF=BC=8C=E5=8D=B3=E5=B0=86=E4=B8=8A=E6=98=A0?=
=?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=A2=84=E5=91=8A=E7=89=87?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/api/tmdb/videos/route.ts | 117 +++++++++++++++++
src/app/page.tsx | 1 +
src/components/MobileActionSheet.tsx | 4 +-
src/components/TrailerPickerDialog.tsx | 166 +++++++++++++++++++++++++
src/components/VideoCard.tsx | 87 ++++++++++++-
src/lib/tmdb.client.ts | 86 +++++++++++++
6 files changed, 454 insertions(+), 7 deletions(-)
create mode 100644 src/app/api/tmdb/videos/route.ts
create mode 100644 src/components/TrailerPickerDialog.tsx
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 = ({
@@ -42,6 +43,7 @@ const MobileActionSheet: React.FC = ({
totalEpisodes,
origin = 'vod',
onPosterClick,
+ description = '选择操作',
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
@@ -322,7 +324,7 @@ 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(
+
+
+
+
+
+
+
+ 选择预告片
+
+
+ {title}
+
+
+
+
+
+
+ {loading ? (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ ) : error ? (
+
+
{error}
+ {onRetry && (
+
+ )}
+
+ ) : sortedVideos.length === 0 ? (
+
+
+
+ 未找到预告片
+
+
+ 该影片在 TMDB 中没有可用的 YouTube 视频。
+
+
+ ) : (
+
+ {sortedVideos.map((video) => (
+
+ ))}
+
+ )}
+
+
+
,
+ document.body
+ );
+}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx
index 2bf72f8..de7b594 100644
--- a/src/components/VideoCard.tsx
+++ b/src/components/VideoCard.tsx
@@ -10,6 +10,7 @@ import {
Radio,
Sparkles,
Trash2,
+ Youtube,
} from 'lucide-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
@@ -50,6 +51,8 @@ import DetailPanel from '@/components/DetailPanel';
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
import ImageViewer from '@/components/ImageViewer';
import MobileActionSheet from '@/components/MobileActionSheet';
+import TrailerPickerDialog from '@/components/TrailerPickerDialog';
+import type { TMDBVideoItem } from '@/lib/tmdb.client';
export interface VideoCardProps {
id?: string;
@@ -170,6 +173,10 @@ const VideoCard = forwardRef(
const [showDetailPanel, setShowDetailPanel] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示
+ const [showTrailerPicker, setShowTrailerPicker] = useState(false);
+ const [trailerLoading, setTrailerLoading] = useState(false);
+ const [trailerError, setTrailerError] = useState(null);
+ const [trailerVideos, setTrailerVideos] = useState([]);
const [displayPoster, setDisplayPoster] = useState(processedPoster);
// 检查AI功能是否启用
@@ -664,6 +671,43 @@ const VideoCard = forwardRef(
return configs[from] || configs.search;
}, [from, isAggregate, douban_id, rate, isUpcoming]);
+ const upcomingReleaseText = useMemo(() => {
+ if (!isUpcoming || daysUntilRelease === null) return undefined;
+ if (daysUntilRelease > 0) return `${daysUntilRelease}天后上映`;
+ if (daysUntilRelease === 0) return '今日上映';
+ return '已上映';
+ }, [isUpcoming, daysUntilRelease]);
+
+
+ const openTrailerPicker = useCallback(async () => {
+ if (!actualTitle) return;
+ setShowMobileActions(false);
+ setTrailerError(null);
+ setTrailerLoading(true);
+ setShowTrailerPicker(true);
+
+ try {
+ const params = new URLSearchParams();
+ if (tmdb_id) params.set('id', String(tmdb_id));
+ if (actualSearchType) params.set('type', actualSearchType);
+ if (actualTitle) params.set('title', actualTitle);
+ if (actualYear) params.set('year', actualYear);
+
+ const res = await fetch(`/api/tmdb/videos?${params.toString()}`);
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data?.error || '获取预告片失败');
+ }
+
+ setTrailerVideos(data.videos || []);
+ } catch (err) {
+ setTrailerVideos([]);
+ setTrailerError(err instanceof Error ? err.message : '获取预告片失败');
+ } finally {
+ setTrailerLoading(false);
+ }
+ }, [actualSearchType, actualTitle, actualYear, tmdb_id]);
+
// 移动端操作菜单配置
const mobileActions = useMemo(() => {
const actions = [];
@@ -796,6 +840,17 @@ const VideoCard = forwardRef(
});
}
+ // 预告片操作:仅即将上映卡片显示
+ if (origin !== 'live' && isUpcoming && actualTitle) {
+ actions.push({
+ id: 'trailer',
+ label: '预告片',
+ icon: ,
+ onClick: openTrailerPicker,
+ color: 'default' as const,
+ });
+ }
+
// 详情页面按钮(直播源不显示详情)
if (origin !== 'live') {
actions.push({
@@ -848,6 +903,11 @@ const VideoCard = forwardRef(
handlePlayInNewTab,
aiEnabled,
actualTitle,
+ actualSearchType,
+ isUpcoming,
+ origin,
+ tmdb_id,
+ openTrailerPicker,
]);
return (
@@ -1039,7 +1099,6 @@ const VideoCard = forwardRef(
{/* 播放按钮或上映倒计时 */}
{isUpcoming && daysUntilRelease !== null ? (
(
} as React.CSSProperties
}
>
- {daysUntilRelease > 0
- ? `${daysUntilRelease}天后上映`
- : daysUntilRelease === 0
- ? '今日上映'
- : '已上映'}
+ {upcomingReleaseText}
) : (
@@ -1963,11 +2018,31 @@ const VideoCard = forwardRef(
currentEpisode={currentEpisode}
totalEpisodes={actualEpisodes}
origin={origin}
+ description={upcomingReleaseText}
onPosterClick={() => {
setShowImageViewer(true);
}}
/>
+
+ setShowTrailerPicker(false)}
+ onRetry={openTrailerPicker}
+ onSelect={(video) => {
+ window.open(
+ `https://www.youtube.com/watch?v=${video.key}`,
+ '_blank',
+ 'noopener,noreferrer'
+ );
+ setShowTrailerPicker(false);
+ }}
+ />
+
{/* AI问片面板 - 只在打开或正在流式响应时渲染 */}
{aiEnabled && (showAIChat || isAIStreaming) && (
{
+ try {
+ const actualKey = getNextApiKey(apiKey);
+ if (!actualKey) {
+ return { code: 400, videos: [] };
+ }
+
+ const baseUrl = reverseProxyBaseUrl || DEFAULT_TMDB_BASE_URL;
+ // 一次性请求中文、英文和未标语言视频,避免按语言多次请求。
+ // TMDB videos 接口支持 include_video_language 传逗号分隔的多个 ISO-639-1 值。
+ const url = `${baseUrl}/3/${mediaType}/${mediaId}/videos?api_key=${actualKey}&include_video_language=zh,en,null`;
+ const response = await universalFetch(url, proxy);
+
+ if (!response.ok) {
+ return { code: response.status, videos: [] };
+ }
+
+ const data: any = await response.json();
+ const videoMap = new Map();
+ (data.results || [])
+ .filter((video: any) => video?.site === 'YouTube' && video?.key)
+ .forEach((video: any) => {
+ if (videoMap.has(video.key)) return;
+ videoMap.set(video.key, {
+ id: String(video.id ?? video.key),
+ key: video.key,
+ name: video.name || '未命名视频',
+ site: video.site || 'YouTube',
+ type: video.type || '',
+ official: video.official,
+ published_at: video.published_at,
+ iso_639_1: video.iso_639_1,
+ });
+ });
+
+ const typePriority: Record = {
+ Trailer: 0,
+ Teaser: 1,
+ Clip: 2,
+ Featurette: 3,
+ };
+
+ const videos = Array.from(videoMap.values()).sort((a, b) => {
+ const officialDiff = (b.official ? 1 : 0) - (a.official ? 1 : 0);
+ if (officialDiff !== 0) return officialDiff;
+ const typeDiff = (typePriority[a.type] ?? 99) - (typePriority[b.type] ?? 99);
+ if (typeDiff !== 0) return typeDiff;
+ return (b.published_at || '').localeCompare(a.published_at || '');
+ });
+
+ return {
+ code: 200,
+ videos,
+ };
+ } catch (error) {
+ console.error('获取 TMDB 视频列表失败:', error);
+ return { code: 500, videos: [] };
+ }
+}
+
/**
* 获取热门内容(电影+电视剧)
* @param apiKey - TMDB API Key