修复即将上映需多次点击才显示,即将上映增加预告片
This commit is contained in:
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -852,6 +852,7 @@ function HomeClient() {
|
|||||||
}
|
}
|
||||||
type={item.media_type === 'tv' ? 'tv' : 'movie'}
|
type={item.media_type === 'tv' ? 'tv' : 'movie'}
|
||||||
from='douban'
|
from='douban'
|
||||||
|
tmdb_id={item.id}
|
||||||
releaseDate={item.release_date}
|
releaseDate={item.release_date}
|
||||||
isUpcoming={true}
|
isUpcoming={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface MobileActionSheetProps {
|
|||||||
totalEpisodes?: number; // 总集数
|
totalEpisodes?: number; // 总集数
|
||||||
origin?: 'vod' | 'live';
|
origin?: 'vod' | 'live';
|
||||||
onPosterClick?: () => void; // 海报点击回调
|
onPosterClick?: () => void; // 海报点击回调
|
||||||
|
description?: string; // 标题下方描述文案
|
||||||
}
|
}
|
||||||
|
|
||||||
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||||
@@ -42,6 +43,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||||||
totalEpisodes,
|
totalEpisodes,
|
||||||
origin = 'vod',
|
origin = 'vod',
|
||||||
onPosterClick,
|
onPosterClick,
|
||||||
|
description = '选择操作',
|
||||||
}) => {
|
}) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
@@ -322,7 +324,7 @@ const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
选择操作
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Youtube,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -50,6 +51,8 @@ import DetailPanel from '@/components/DetailPanel';
|
|||||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||||
import ImageViewer from '@/components/ImageViewer';
|
import ImageViewer from '@/components/ImageViewer';
|
||||||
import MobileActionSheet from '@/components/MobileActionSheet';
|
import MobileActionSheet from '@/components/MobileActionSheet';
|
||||||
|
import TrailerPickerDialog from '@/components/TrailerPickerDialog';
|
||||||
|
import type { TMDBVideoItem } from '@/lib/tmdb.client';
|
||||||
|
|
||||||
export interface VideoCardProps {
|
export interface VideoCardProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -170,6 +173,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
const [showDetailPanel, setShowDetailPanel] = useState(false);
|
||||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||||
const [showUpcomingInfo, setShowUpcomingInfo] = 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);
|
const [displayPoster, setDisplayPoster] = useState(processedPoster);
|
||||||
|
|
||||||
// 检查AI功能是否启用
|
// 检查AI功能是否启用
|
||||||
@@ -664,6 +671,43 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
return configs[from] || configs.search;
|
return configs[from] || configs.search;
|
||||||
}, [from, isAggregate, douban_id, rate, isUpcoming]);
|
}, [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 mobileActions = useMemo(() => {
|
||||||
const actions = [];
|
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') {
|
if (origin !== 'live') {
|
||||||
actions.push({
|
actions.push({
|
||||||
@@ -848,6 +903,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
handlePlayInNewTab,
|
handlePlayInNewTab,
|
||||||
aiEnabled,
|
aiEnabled,
|
||||||
actualTitle,
|
actualTitle,
|
||||||
|
actualSearchType,
|
||||||
|
isUpcoming,
|
||||||
|
origin,
|
||||||
|
tmdb_id,
|
||||||
|
openTrailerPicker,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1039,7 +1099,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
{/* 播放按钮或上映倒计时 */}
|
{/* 播放按钮或上映倒计时 */}
|
||||||
{isUpcoming && daysUntilRelease !== null ? (
|
{isUpcoming && daysUntilRelease !== null ? (
|
||||||
<div
|
<div
|
||||||
data-button='true'
|
|
||||||
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out ${
|
className={`absolute inset-0 flex items-center justify-center transition-all duration-300 ease-in-out ${
|
||||||
showUpcomingInfo
|
showUpcomingInfo
|
||||||
? 'opacity-100 scale-100'
|
? 'opacity-100 scale-100'
|
||||||
@@ -1067,11 +1126,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{daysUntilRelease > 0
|
{upcomingReleaseText}
|
||||||
? `${daysUntilRelease}天后上映`
|
|
||||||
: daysUntilRelease === 0
|
|
||||||
? '今日上映'
|
|
||||||
: '已上映'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1963,11 +2018,31 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
currentEpisode={currentEpisode}
|
currentEpisode={currentEpisode}
|
||||||
totalEpisodes={actualEpisodes}
|
totalEpisodes={actualEpisodes}
|
||||||
origin={origin}
|
origin={origin}
|
||||||
|
description={upcomingReleaseText}
|
||||||
onPosterClick={() => {
|
onPosterClick={() => {
|
||||||
setShowImageViewer(true);
|
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问片面板 - 只在打开或正在流式响应时渲染 */}
|
{/* AI问片面板 - 只在打开或正在流式响应时渲染 */}
|
||||||
{aiEnabled && (showAIChat || isAIStreaming) && (
|
{aiEnabled && (showAIChat || isAIStreaming) && (
|
||||||
<AIChatPanel
|
<AIChatPanel
|
||||||
|
|||||||
@@ -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
|
* @param apiKey - TMDB API Key
|
||||||
|
|||||||
Reference in New Issue
Block a user