增加play页背景图渲染

This commit is contained in:
mtvpls
2025-12-28 00:51:02 +08:00
parent cc6d1e95f3
commit 0cf4db35f9
4 changed files with 443 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
import { NextRequest, NextResponse } from 'next/server';
import {
searchTMDBMulti,
getTMDBMovieDetails,
getTMDBTVDetails,
getTMDBImageUrl,
} from '@/lib/tmdb.client';
import { getConfig } from '@/lib/config';
// 服务器端缓存(内存)
const searchCache = new Map<
string,
{ data: { tmdbId: number; mediaType: 'movie' | 'tv' }; timestamp: number }
>();
const CACHE_TTL = 24 * 60 * 60 * 1000; // 1天
// 移除季度信息的辅助函数
function removeSeasonInfo(title: string): string {
return title
.replace(/第[一二三四五六七八九十\d]+[(]\d+[)][季部]/g, '')
.replace(/第[一二三四五六七八九十\d]+[季部]/g, '')
.replace(/[(]\d+[)]/g, '')
.replace(/\s+season\s+\d+/gi, '')
.replace(/\s+S\d+/gi, '')
.trim();
}
// 精确匹配标题
function findExactMatch(results: any[], originalTitle: string): any | null {
if (!results || results.length === 0) return null;
if (results.length === 1) return results[0];
const cleanTitle = originalTitle.toLowerCase().trim();
// 尝试精确匹配
for (const result of results) {
const resultTitle = (result.title || result.name || '').toLowerCase().trim();
if (resultTitle === cleanTitle) {
return result;
}
}
// 如果没有精确匹配,返回第一个
return results[0];
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const title = searchParams.get('title');
const cachedId = searchParams.get('cachedId');
if (!title && !cachedId) {
return NextResponse.json(
{ error: '缺少 title 或 cachedId 参数' },
{ status: 400 }
);
}
// 获取配置
const config = await getConfig();
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
const tmdbProxy = config.SiteConfig.TMDBProxy;
if (!tmdbApiKey) {
return NextResponse.json(
{ error: '未配置 TMDB API Key' },
{ status: 500 }
);
}
let tmdbId: number;
let mediaType: 'movie' | 'tv';
// 如果提供了cachedId直接使用
if (cachedId) {
const [type, id] = cachedId.split(':');
mediaType = type as 'movie' | 'tv';
tmdbId = parseInt(id, 10);
} else {
// 否则需要搜索获取ID
const cleanedTitle = removeSeasonInfo(title!);
const cacheKey = `search_${cleanedTitle}`;
// 检查服务器缓存
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
console.log('使用服务器缓存的搜索结果');
tmdbId = cached.data.tmdbId;
mediaType = cached.data.mediaType;
} else {
// 搜索TMDB
console.log('搜索TMDB:', cleanedTitle);
const searchResult = await searchTMDBMulti(
tmdbApiKey,
cleanedTitle,
tmdbProxy
);
if (searchResult.code !== 200 || !searchResult.results.length) {
return NextResponse.json(
{ error: '未找到匹配的内容' },
{ status: 404 }
);
}
// 精确匹配
const match = findExactMatch(searchResult.results, cleanedTitle);
if (!match) {
return NextResponse.json(
{ error: '未找到匹配的内容' },
{ status: 404 }
);
}
tmdbId = match.id;
mediaType = match.media_type;
// 保存到服务器缓存
searchCache.set(cacheKey, {
data: { tmdbId, mediaType },
timestamp: Date.now(),
});
// 清理过期缓存
Array.from(searchCache.entries()).forEach(([key, value]) => {
if (Date.now() - value.timestamp > CACHE_TTL) {
searchCache.delete(key);
}
});
}
}
// 获取详情
let detailsResult;
if (mediaType === 'movie') {
detailsResult = await getTMDBMovieDetails(tmdbApiKey, tmdbId, tmdbProxy);
} else {
detailsResult = await getTMDBTVDetails(tmdbApiKey, tmdbId, tmdbProxy);
}
if (detailsResult.code !== 200 || !detailsResult.details) {
return NextResponse.json(
{ error: '获取详情失败' },
{ status: detailsResult.code }
);
}
const details = detailsResult.details;
// 构建返回数据
const responseData = {
tmdbId: `${mediaType}:${tmdbId}`, // 用于缓存
mediaType,
title: details.title || details.name,
backdrop: details.backdrop_path
? getTMDBImageUrl(details.backdrop_path, 'w1280')
: null,
poster: details.poster_path
? getTMDBImageUrl(details.poster_path, 'w500')
: null,
overview: details.overview || '',
rating: details.vote_average ? details.vote_average.toFixed(1) : '',
releaseDate: details.release_date || details.first_air_date || '',
};
return NextResponse.json(responseData, {
status: 200,
headers: {
'Cache-Control': 'public, max-age=86400', // 浏览器缓存1天
},
});
} catch (error) {
console.error('获取 TMDB 详情失败:', error);
return NextResponse.json(
{ error: '获取详情失败' },
{ status: 500 }
);
}
}

View File

@@ -95,6 +95,9 @@ function PlayPageClient() {
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<SearchResult | null>(null);
// TMDB背景图
const [tmdbBackdrop, setTmdbBackdrop] = useState<string | null>(null);
// 收藏状态
const [favorited, setFavorited] = useState(false);
@@ -562,6 +565,118 @@ function PlayPageClient() {
fetchDoubanRating();
}, [videoDoubanId]);
// 获取TMDB背景图
useEffect(() => {
const fetchTMDBBackdrop = async () => {
// 检查是否禁用背景图
if (typeof window !== 'undefined') {
const disabled = localStorage.getItem('tmdb_backdrop_disabled');
if (disabled === 'true') {
setTmdbBackdrop(null);
return;
}
}
if (!videoTitle) {
setTmdbBackdrop(null);
return;
}
try {
// 检查title到tmdbId的映射缓存1个月
const mappingCacheKey = `tmdb_title_mapping_${videoTitle}`;
const mappingCache = localStorage.getItem(mappingCacheKey);
let cachedId: string | null = null;
if (mappingCache) {
try {
const { tmdbId, timestamp } = JSON.parse(mappingCache);
const cacheAge = Date.now() - timestamp;
const cacheMaxAge = 30 * 24 * 60 * 60 * 1000; // 1个月
if (cacheAge < cacheMaxAge && tmdbId) {
console.log('使用缓存的TMDB ID映射');
cachedId = tmdbId;
// 检查TMDB详情缓存1天
const detailsCacheKey = `tmdb_details_${tmdbId}`;
const detailsCache = localStorage.getItem(detailsCacheKey);
if (detailsCache) {
try {
const { data, timestamp: detTimestamp } = JSON.parse(detailsCache);
const detCacheAge = Date.now() - detTimestamp;
const detCacheMaxAge = 24 * 60 * 60 * 1000; // 1天
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
console.log('使用缓存的TMDB详情数据');
setTmdbBackdrop(data.backdrop);
return;
}
} catch (e) {
console.error('解析详情缓存失败:', e);
}
}
}
} catch (e) {
console.error('解析映射缓存失败:', e);
}
}
// 构建请求URL
const url = cachedId
? `/api/tmdb-details?cachedId=${encodeURIComponent(cachedId)}`
: `/api/tmdb-details?title=${encodeURIComponent(videoTitle)}`;
const response = await fetch(url);
if (!response.ok) {
console.log('获取TMDB详情失败');
setTmdbBackdrop(null);
return;
}
const result = await response.json();
if (result.backdrop) {
setTmdbBackdrop(result.backdrop);
// 保存title到tmdbId的映射到localStorage1个月
if (result.tmdbId) {
try {
localStorage.setItem(
mappingCacheKey,
JSON.stringify({
tmdbId: result.tmdbId,
timestamp: Date.now(),
})
);
// 保存TMDB详情数据到localStorage1天
const detailsCacheKey = `tmdb_details_${result.tmdbId}`;
localStorage.setItem(
detailsCacheKey,
JSON.stringify({
data: result,
timestamp: Date.now(),
})
);
} catch (e) {
console.error('保存缓存失败:', e);
}
}
} else {
setTmdbBackdrop(null);
}
} catch (error) {
console.error('获取TMDB背景图失败:', error);
setTmdbBackdrop(null);
}
};
fetchTMDBBackdrop();
}, [videoTitle]);
// 视频播放地址
const [videoUrl, setVideoUrl] = useState('');
@@ -4405,6 +4520,19 @@ function PlayPageClient() {
return (
<PageLayout activePath='/play'>
{/* TMDB背景图 */}
{tmdbBackdrop && (
<div
className='fixed inset-0 z-0'
style={{
backgroundImage: `url(${tmdbBackdrop})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
filter: 'blur(5px) brightness(0.7)',
}}
/>
)}
{/* 弹幕源选择对话框 */}
{showDanmakuSourceSelector && danmakuMatches.length > 0 && (
<div className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/60 backdrop-blur-sm'>

View File

@@ -90,6 +90,7 @@ export const UserMenu: React.FC = () => {
const [fluidSearch, setFluidSearch] = useState(true);
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
@@ -309,6 +310,11 @@ export const UserMenu: React.FC = () => {
if (savedDanmakuHeatmapDisabled !== null) {
setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true');
}
const savedTmdbBackdropDisabled = localStorage.getItem('tmdb_backdrop_disabled');
if (savedTmdbBackdropDisabled !== null) {
setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true');
}
}
}, []);
@@ -513,6 +519,13 @@ export const UserMenu: React.FC = () => {
}
};
const handleTmdbBackdropDisabledToggle = (value: boolean) => {
setTmdbBackdropDisabled(value);
if (typeof window !== 'undefined') {
localStorage.setItem('tmdb_backdrop_disabled', String(value));
}
};
const handleDoubanDataSourceChange = (value: string) => {
setDoubanDataSource(value);
if (typeof window !== 'undefined') {
@@ -1204,6 +1217,30 @@ export const UserMenu: React.FC = () => {
</label>
</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'>
TMDB背景图显示
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={tmdbBackdropDisabled}
onChange={(e) => handleTmdbBackdropDisabledToggle(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>

View File

@@ -503,3 +503,101 @@ export async function getTMDBTVRecommendations(
return { code: 500, results: [] };
}
}
/**
* 获取 TMDB 电影详情
* @param apiKey - TMDB API Key
* @param movieId - 电影ID
* @param proxy - 代理服务器地址
* @returns 电影详情
*/
export async function getTMDBMovieDetails(
apiKey: string,
movieId: number,
proxy?: string
): Promise<{ code: number; details: any }> {
try {
if (!apiKey) {
return { code: 400, details: null };
}
const url = `https://api.themoviedb.org/3/movie/${movieId}?api_key=${apiKey}&language=zh-CN`;
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) {
console.error('TMDB API 请求失败:', response.status, response.statusText);
return { code: response.status, details: null };
}
const data: any = await response.json();
return {
code: 200,
details: data,
};
} catch (error) {
console.error('获取 TMDB 电影详情失败:', error);
return { code: 500, details: null };
}
}
/**
* 获取 TMDB 电视剧详情
* @param apiKey - TMDB API Key
* @param tvId - 电视剧ID
* @param proxy - 代理服务器地址
* @returns 电视剧详情
*/
export async function getTMDBTVDetails(
apiKey: string,
tvId: number,
proxy?: string
): Promise<{ code: number; details: any }> {
try {
if (!apiKey) {
return { code: 400, details: null };
}
const url = `https://api.themoviedb.org/3/tv/${tvId}?api_key=${apiKey}&language=zh-CN`;
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) {
console.error('TMDB API 请求失败:', response.status, response.statusText);
return { code: response.status, details: null };
}
const data: any = await response.json();
return {
code: 200,
details: data,
};
} catch (error) {
console.error('获取 TMDB 电视剧详情失败:', error);
return { code: 500, details: null };
}
}