修复即将上映需多次点击才显示,即将上映增加预告片

This commit is contained in:
mtvpls
2026-06-02 10:34:13 +08:00
rodzic 135466b260
commit 16e48bef8b
6 zmienionych plików z 454 dodań i 7 usunięć
+117
Wyświetl plik
@@ -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 }
);
}
}
+1
Wyświetl plik
@@ -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}
/>
+3 -1
Wyświetl plik
@@ -26,6 +26,7 @@ interface MobileActionSheetProps {
totalEpisodes?: number; // 总集数
origin?: 'vod' | 'live';
onPosterClick?: () => void; // 海报点击回调
description?: string; // 标题下方描述文案
}
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
@@ -42,6 +43,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
totalEpisodes,
origin = 'vod',
onPosterClick,
description = '选择操作',
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
@@ -322,7 +324,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
</p>
)}
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
</div>
+166
Wyświetl plik
@@ -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(
<div className='fixed inset-0 z-[10000] flex items-end sm:items-center justify-center'>
<div className='absolute inset-0 bg-black/60 backdrop-blur-sm' onClick={onClose} />
<div className='relative w-full sm:max-w-2xl max-h-[85vh] overflow-hidden rounded-t-2xl sm:rounded-2xl bg-white dark:bg-gray-900 shadow-2xl border border-gray-200 dark:border-gray-700'>
<div className='flex items-start justify-between gap-4 border-b border-gray-200 dark:border-gray-800 px-4 sm:px-6 py-4'>
<div className='min-w-0'>
<div className='flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm mb-1'>
<Film size={16} />
</div>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
{title}
</h3>
</div>
<button
onClick={onClose}
className='rounded-full p-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 transition-colors'
aria-label='关闭'
>
<X size={20} />
</button>
</div>
<div className='max-h-[calc(85vh-72px)] overflow-y-auto p-4 sm:p-6'>
{loading ? (
<div className='space-y-3'>
{[0, 1, 2].map((i) => (
<div
key={i}
className='flex items-center gap-4 rounded-xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/40 px-4 py-4 animate-pulse'
>
<div className='h-11 w-11 rounded-full bg-gray-200 dark:bg-gray-700' />
<div className='flex-1 space-y-2'>
<div className='h-4 w-2/3 rounded bg-gray-200 dark:bg-gray-700' />
<div className='h-3 w-1/3 rounded bg-gray-200 dark:bg-gray-700' />
</div>
</div>
))}
</div>
) : error ? (
<div className='rounded-xl border border-red-200 dark:border-red-900/60 bg-red-50 dark:bg-red-950/30 p-4 text-sm text-red-700 dark:text-red-300'>
<div className='mb-3 font-medium'>{error}</div>
{onRetry && (
<button
onClick={onRetry}
className='inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-white transition-colors hover:bg-red-700'
>
<Loader2 size={16} className='animate-spin' />
</button>
)}
</div>
) : sortedVideos.length === 0 ? (
<div className='flex flex-col items-center justify-center rounded-2xl border border-dashed border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/30 px-6 py-12 text-center'>
<Youtube className='mb-3 h-12 w-12 text-gray-400' />
<h4 className='text-base font-medium text-gray-900 dark:text-gray-100'>
</h4>
<p className='mt-2 text-sm text-gray-500 dark:text-gray-400'>
TMDB YouTube
</p>
</div>
) : (
<div className='space-y-3'>
{sortedVideos.map((video) => (
<button
key={video.id}
onClick={() => onSelect(video)}
className='group w-full rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-850 p-4 text-left transition-all hover:-translate-y-0.5 hover:border-red-300 dark:hover:border-red-700 hover:shadow-lg hover:shadow-red-500/5'
>
<div className='flex items-start gap-4'>
<div className='flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-400'>
<PlayCircle size={22} />
</div>
<div className='min-w-0 flex-1'>
<div className='flex flex-wrap items-center gap-2'>
<h4 className='truncate text-sm font-semibold text-gray-900 dark:text-gray-100'>
{video.name}
</h4>
{video.official && (
<span className='rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-950/40 dark:text-blue-300'>
</span>
)}
{video.type && (
<span className='rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300'>
{video.type}
</span>
)}
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{video.site} · {video.published_at || '发布时间未知'}
</p>
</div>
<ExternalLink size={16} className='mt-1 shrink-0 text-gray-400 transition-colors group-hover:text-red-500' />
</div>
</button>
))}
</div>
)}
</div>
</div>
</div>,
document.body
);
}
+81 -6
Wyświetl plik
@@ -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<VideoCardHandle, VideoCardProps>(
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<string | null>(null);
const [trailerVideos, setTrailerVideos] = useState<TMDBVideoItem[]>([]);
const [displayPoster, setDisplayPoster] = useState(processedPoster);
// 检查AI功能是否启用
@@ -664,6 +671,43 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
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<VideoCardHandle, VideoCardProps>(
});
}
// 预告片操作:仅即将上映卡片显示
if (origin !== 'live' && isUpcoming && actualTitle) {
actions.push({
id: 'trailer',
label: '预告片',
icon: <Youtube size={20} />,
onClick: openTrailerPicker,
color: 'default' as const,
});
}
// 详情页面按钮(直播源不显示详情)
if (origin !== 'live') {
actions.push({
@@ -848,6 +903,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
handlePlayInNewTab,
aiEnabled,
actualTitle,
actualSearchType,
isUpcoming,
origin,
tmdb_id,
openTrailerPicker,
]);
return (
@@ -1039,7 +1099,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
{/* 播放按钮或上映倒计时 */}
{isUpcoming && daysUntilRelease !== null ? (
<div
data-button='true'
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out ${
showUpcomingInfo
? 'opacity-100 scale-100'
@@ -1067,11 +1126,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
} as React.CSSProperties
}
>
{daysUntilRelease > 0
? `${daysUntilRelease}天后上映`
: daysUntilRelease === 0
? '今日上映'
: '已上映'}
{upcomingReleaseText}
</div>
</div>
) : (
@@ -1963,11 +2018,31 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
currentEpisode={currentEpisode}
totalEpisodes={actualEpisodes}
origin={origin}
description={upcomingReleaseText}
onPosterClick={() => {
setShowImageViewer(true);
}}
/>
<TrailerPickerDialog
isOpen={showTrailerPicker}
title={actualTitle}
loading={trailerLoading}
error={trailerError}
videos={trailerVideos}
onClose={() => 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) && (
<AIChatPanel
+86
Wyświetl plik
@@ -328,6 +328,92 @@ export async function getTMDBVideos(
}
}
export interface TMDBVideoItem {
id: string;
key: string;
name: string;
site: string;
type: string;
official?: boolean;
published_at?: string;
iso_639_1?: string;
}
/**
* 获取视频列表(预告片/花絮等)
* @param apiKey - TMDB API Key
* @param mediaType - 媒体类型 (movie 或 tv)
* @param mediaId - 媒体ID
* @param proxy - 代理服务器地址
* @param reverseProxyBaseUrl - 反代 Base URL
* @returns YouTube视频列表
*/
export async function getTMDBVideoList(
apiKey: string,
mediaType: 'movie' | 'tv',
mediaId: number,
proxy?: string,
reverseProxyBaseUrl?: string
): Promise<{ code: number; videos: TMDBVideoItem[] }> {
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<string, TMDBVideoItem>();
(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<string, number> = {
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