tmdb轮播新增预告片显示
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getTMDBTrendingContent } from '@/lib/tmdb.client';
|
import { getTMDBTrendingContent, getTMDBVideos } from '@/lib/tmdb.client';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
// 缓存配置 - 服务器内存缓存3小时
|
// 缓存配置 - 服务器内存缓存3小时
|
||||||
@@ -54,6 +54,18 @@ export async function GET() {
|
|||||||
|
|
||||||
// 获取热门内容
|
// 获取热门内容
|
||||||
result = await getTMDBTrendingContent(apiKey, proxy);
|
result = await getTMDBTrendingContent(apiKey, proxy);
|
||||||
|
|
||||||
|
// 为每个项目获取视频数据
|
||||||
|
if (result.code === 200 && result.list) {
|
||||||
|
const itemsWithVideos = await Promise.all(
|
||||||
|
result.list.map(async (item: any) => {
|
||||||
|
const videoKey = await getTMDBVideos(apiKey, item.media_type, item.id, proxy);
|
||||||
|
return { ...item, video_key: videoKey };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
result.list = itemsWithVideos;
|
||||||
|
}
|
||||||
|
|
||||||
// 添加数据源标识
|
// 添加数据源标识
|
||||||
result.source = 'TMDB';
|
result.source = 'TMDB';
|
||||||
// 更新TMDB缓存
|
// 更新TMDB缓存
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [skipNextAutoPlay, setSkipNextAutoPlay] = useState(false); // 跳过下一次自动播放
|
const [skipNextAutoPlay, setSkipNextAutoPlay] = useState(false); // 跳过下一次自动播放
|
||||||
|
const [isYouTubeAccessible, setIsYouTubeAccessible] = useState(false); // YouTube连通性(默认false,检查后再决定)
|
||||||
|
const [enableTrailers, setEnableTrailers] = useState(false); // 是否启用预告片(默认关闭)
|
||||||
const touchStartX = useRef(0);
|
const touchStartX = useRef(0);
|
||||||
const touchEndX = useRef(0);
|
const touchEndX = useRef(0);
|
||||||
const isManualChange = useRef(false); // 标记是否为手动切换
|
const isManualChange = useRef(false); // 标记是否为手动切换
|
||||||
@@ -51,6 +53,39 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
|||||||
return getTMDBImageUrl(path, 'original');
|
return getTMDBImageUrl(path, 'original');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 读取本地设置
|
||||||
|
useEffect(() => {
|
||||||
|
const setting = localStorage.getItem('enableTrailers');
|
||||||
|
if (setting !== null) {
|
||||||
|
setEnableTrailers(setting === 'true');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 检测YouTube连通性
|
||||||
|
useEffect(() => {
|
||||||
|
const checkYouTubeAccess = () => {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
img.src = '';
|
||||||
|
setIsYouTubeAccessible(false);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
setIsYouTubeAccessible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
setIsYouTubeAccessible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = 'https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg';
|
||||||
|
};
|
||||||
|
|
||||||
|
checkYouTubeAccess();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 获取热门内容
|
// 获取热门内容
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTrending = async () => {
|
const fetchTrending = async () => {
|
||||||
@@ -234,7 +269,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 背景图片 */}
|
{/* 背景图片或视频 */}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
@@ -243,14 +278,34 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
|
|||||||
index === currentIndex ? 'opacity-100' : 'opacity-0'
|
index === currentIndex ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Image
|
{item.video_key && isYouTubeAccessible && enableTrailers ? (
|
||||||
src={getImageUrl(item.backdrop_path || item.poster_path)}
|
/* 显示YouTube视频 */
|
||||||
alt={item.title}
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
fill
|
<iframe
|
||||||
className="object-cover"
|
src={`https://www.youtube.com/embed/${item.video_key}?listType=playlist&autoplay=1&mute=1&controls=0&loop=1&playlist=${item.video_key}&modestbranding=1&rel=0&showinfo=0&vq=hd1080&hd=1&disablekb=1&fs=0&iv_load_policy=3`}
|
||||||
priority={index === 0}
|
className="absolute top-1/2 left-1/2 pointer-events-none"
|
||||||
sizes="100vw"
|
allow="autoplay; encrypted-media"
|
||||||
/>
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
minWidth: '100%',
|
||||||
|
minHeight: '100%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 显示图片 */
|
||||||
|
<Image
|
||||||
|
src={getImageUrl(item.backdrop_path || item.poster_path)}
|
||||||
|
alt={item.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority={index === 0}
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* 渐变遮罩 */}
|
{/* 渐变遮罩 */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/50 to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/50 to-transparent"></div>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
|
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
|
||||||
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
|
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
|
||||||
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
|
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
|
||||||
|
const [enableTrailers, setEnableTrailers] = useState(false);
|
||||||
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
||||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
|
||||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||||
@@ -327,6 +328,11 @@ export const UserMenu: React.FC = () => {
|
|||||||
setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true');
|
setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedEnableTrailers = localStorage.getItem('enableTrailers');
|
||||||
|
if (savedEnableTrailers !== null) {
|
||||||
|
setEnableTrailers(savedEnableTrailers === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
const savedBufferStrategy = localStorage.getItem('bufferStrategy');
|
const savedBufferStrategy = localStorage.getItem('bufferStrategy');
|
||||||
if (savedBufferStrategy !== null) {
|
if (savedBufferStrategy !== null) {
|
||||||
setBufferStrategy(savedBufferStrategy);
|
setBufferStrategy(savedBufferStrategy);
|
||||||
@@ -564,6 +570,13 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEnableTrailersToggle = (value: boolean) => {
|
||||||
|
setEnableTrailers(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('enableTrailers', String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDoubanDataSourceChange = (value: string) => {
|
const handleDoubanDataSourceChange = (value: string) => {
|
||||||
setDoubanDataSource(value);
|
setDoubanDataSource(value);
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -635,6 +648,8 @@ export const UserMenu: React.FC = () => {
|
|||||||
setFluidSearch(defaultFluidSearch);
|
setFluidSearch(defaultFluidSearch);
|
||||||
setLiveDirectConnect(false);
|
setLiveDirectConnect(false);
|
||||||
setDanmakuHeatmapDisabled(false);
|
setDanmakuHeatmapDisabled(false);
|
||||||
|
setTmdbBackdropDisabled(false);
|
||||||
|
setEnableTrailers(false);
|
||||||
setDoubanProxyUrl(defaultDoubanProxy);
|
setDoubanProxyUrl(defaultDoubanProxy);
|
||||||
setDoubanDataSource(defaultDoubanProxyType);
|
setDoubanDataSource(defaultDoubanProxyType);
|
||||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||||
@@ -648,6 +663,8 @@ export const UserMenu: React.FC = () => {
|
|||||||
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
|
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
|
||||||
localStorage.setItem('liveDirectConnect', JSON.stringify(false));
|
localStorage.setItem('liveDirectConnect', JSON.stringify(false));
|
||||||
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
||||||
|
localStorage.setItem('tmdb_backdrop_disabled', 'false');
|
||||||
|
localStorage.setItem('enableTrailers', 'false');
|
||||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||||
@@ -1297,6 +1314,30 @@ export const UserMenu: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 启用预告片 */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
首页预告片
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
在首页轮播图中显示视频预告片(需刷新页面生效)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={enableTrailers}
|
||||||
|
onChange={(e) => handleEnableTrailersToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||||
|
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 分割线 */}
|
{/* 分割线 */}
|
||||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface TMDBItem {
|
|||||||
vote_average: number;
|
vote_average: number;
|
||||||
media_type: 'movie' | 'tv';
|
media_type: 'movie' | 'tv';
|
||||||
genre_ids?: number[]; // 类型ID列表
|
genre_ids?: number[]; // 类型ID列表
|
||||||
|
video_key?: string; // YouTube视频key
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TMDBUpcomingResponse {
|
interface TMDBUpcomingResponse {
|
||||||
@@ -255,6 +256,60 @@ export async function getTMDBUpcomingContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频(预告片)
|
||||||
|
* @param apiKey - TMDB API Key
|
||||||
|
* @param mediaType - 媒体类型 (movie 或 tv)
|
||||||
|
* @param mediaId - 媒体ID
|
||||||
|
* @param proxy - 代理服务器地址
|
||||||
|
* @returns YouTube视频key(只返回预告片)
|
||||||
|
*/
|
||||||
|
export async function getTMDBVideos(
|
||||||
|
apiKey: string,
|
||||||
|
mediaType: 'movie' | 'tv',
|
||||||
|
mediaId: number,
|
||||||
|
proxy?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const actualKey = getNextApiKey(apiKey);
|
||||||
|
if (!actualKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.themoviedb.org/3/${mediaType}/${mediaId}/videos?api_key=${actualKey}`;
|
||||||
|
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) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: any = await response.json();
|
||||||
|
const videos = data.results || [];
|
||||||
|
|
||||||
|
// 只查找YouTube预告片
|
||||||
|
const trailer = videos.find((v: any) =>
|
||||||
|
v.site === 'YouTube' && v.type === 'Trailer'
|
||||||
|
);
|
||||||
|
|
||||||
|
return trailer?.key || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 TMDB 视频失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取热门内容(电影+电视剧)
|
* 获取热门内容(电影+电视剧)
|
||||||
* @param apiKey - TMDB API Key
|
* @param apiKey - TMDB API Key
|
||||||
|
|||||||
Reference in New Issue
Block a user