增加轮播图高度配置

This commit is contained in:
mtvpls
2026-05-23 11:18:27 +08:00
parent 40ab3346ee
commit f3ec0d3019
2 changed files with 853 additions and 320 deletions
+136 -70
View File
@@ -1,11 +1,21 @@
'use client';
import { ChevronLeft, ChevronRight, Play, Volume2, VolumeX } from 'lucide-react';
import {
ChevronLeft,
ChevronRight,
Play,
Volume2,
VolumeX,
} from 'lucide-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef,useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client';
import {
type TMDBItem,
getGenreNames,
getTMDBImageUrl,
} from '@/lib/tmdb.client';
import { getDoubanDetail } from '@/lib/douban.client';
import ProxyImage from '@/components/ProxyImage';
@@ -15,6 +25,21 @@ interface BannerCarouselProps {
delayLoad?: boolean; // 是否延迟加载(等页面加载完毕后再加载)
}
type HomeBannerHeightScale = '1' | '1.5' | '2';
const bannerHeightClassMap: Record<HomeBannerHeightScale, string> = {
'1': 'h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px]',
'1.5': 'h-[300px] sm:h-[450px] md:h-[600px] lg:h-[750px]',
'2': 'h-[400px] sm:h-[600px] md:h-[800px] lg:h-[1000px]',
};
const getSavedBannerHeightScale = (): HomeBannerHeightScale => {
if (typeof window === 'undefined') return '1';
const saved = localStorage.getItem('homeBannerHeightScale');
return saved === '1.5' || saved === '2' ? saved : '1';
};
// 扩展TMDBItem类型以支持TX数据源的额外字段
interface BannerItem extends TMDBItem {
subtitle?: string; // TX数据源的子标题
@@ -23,7 +48,10 @@ interface BannerItem extends TMDBItem {
genres?: string[]; // 豆瓣数据源的类型标签
}
export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = false }: BannerCarouselProps) {
export default function BannerCarousel({
autoPlayInterval = 5000,
delayLoad = false,
}: BannerCarouselProps) {
const router = useRouter();
const [items, setItems] = useState<BannerItem[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
@@ -36,6 +64,8 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
const [dataSource, setDataSource] = useState<string>(''); // 当前数据源
const [trailersLoaded, setTrailersLoaded] = useState(false); // 预告片是否已加载
const [isMuted, setIsMuted] = useState(true); // 视频是否静音(默认静音)
const [bannerHeightScale, setBannerHeightScale] =
useState<HomeBannerHeightScale>('1'); // 轮播图高度倍率
const videoRef = useRef<HTMLVideoElement>(null); // 视频元素引用
const videoRefs = useRef<Map<number, HTMLVideoElement>>(new Map()); // 所有视频元素的引用
const touchStartX = useRef(0);
@@ -95,6 +125,20 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
if (setting !== null) {
setEnableTrailers(setting === 'true');
}
setBannerHeightScale(getSavedBannerHeightScale());
const handleHomeModulesUpdated = () => {
setBannerHeightScale(getSavedBannerHeightScale());
};
window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated);
return () => {
window.removeEventListener(
'homeModulesUpdated',
handleHomeModulesUpdated
);
};
}, []);
// 延迟加载:等待页面加载完毕后再开始加载轮播图数据
@@ -204,10 +248,13 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
// 保存到 localStorage(使用数据源特定的key
try {
localStorage.setItem(cacheKey, JSON.stringify({
data: result.list,
timestamp: Date.now()
}));
localStorage.setItem(
cacheKey,
JSON.stringify({
data: result.list,
timestamp: Date.now(),
})
);
} catch (e) {
// localStorage 可能已满,忽略错误
console.error('保存到 localStorage 失败:', e);
@@ -227,7 +274,12 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
// 前端获取豆瓣预告片
useEffect(() => {
// 只有在启用预告片、数据源是豆瓣、有数据且未加载预告片时才执行
if (!enableTrailers || dataSource !== 'Douban' || items.length === 0 || trailersLoaded) {
if (
!enableTrailers ||
dataSource !== 'Douban' ||
items.length === 0 ||
trailersLoaded
) {
return;
}
@@ -241,9 +293,10 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
const detail = await getDoubanDetail(item.id.toString());
// 获取预告片链接(取第一个)
const trailerUrl = detail.trailers && detail.trailers.length > 0
? detail.trailers[0].video_url
: null;
const trailerUrl =
detail.trailers && detail.trailers.length > 0
? detail.trailers[0].video_url
: null;
return {
...item,
@@ -298,7 +351,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
setSkipNextAutoPlay(false);
return;
}
setCurrentIndex((prev) => (prev + 1) % items.length);
}, autoPlayInterval);
@@ -350,7 +403,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
// 防止在手动切换过程中触发
if (isManualChange.current) return;
if (!touchStartX.current) return;
// 如果有滑动,则执行滑动逻辑
if (touchEndX.current !== 0) {
const distance = touchStartX.current - touchEndX.current;
@@ -374,13 +427,15 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
if (isLoading || !shouldLoad) {
return (
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] bg-gradient-to-b from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900 overflow-hidden flex items-center justify-center">
<div
className={`relative w-full ${bannerHeightClassMap[bannerHeightScale]} bg-gradient-to-b from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900 overflow-hidden flex items-center justify-center`}
>
<Image
src="/logo.png"
alt="MoonTVPlus"
src='/logo.png'
alt='MoonTVPlus'
width={120}
height={120}
className="opacity-50"
className='opacity-50'
priority
/>
</div>
@@ -395,7 +450,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
return (
<div
className="relative w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] overflow-hidden group"
className={`relative w-full ${bannerHeightClassMap[bannerHeightScale]} overflow-hidden group`}
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onTouchStart={handleTouchStart}
@@ -409,7 +464,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
}}
>
{/* 背景图片或视频 */}
<div className="absolute inset-0">
<div className='absolute inset-0'>
{items.map((item, index) => (
<div
key={item.id}
@@ -419,7 +474,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
>
{item.trailer_url && enableTrailers ? (
/* 显示豆瓣直链视频 */
<div className="absolute inset-0 overflow-hidden">
<div className='absolute inset-0 overflow-hidden'>
<video
ref={(el) => {
if (el) {
@@ -429,20 +484,20 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
}
}}
src={getVideoUrl(item.trailer_url) || undefined}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-full min-h-full w-auto h-auto object-cover"
className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-full min-h-full w-auto h-auto object-cover'
muted={isMuted}
loop
playsInline
preload="metadata"
preload='metadata'
/>
</div>
) : item.video_key && isYouTubeAccessible && enableTrailers ? (
/* 显示YouTube视频 */
<div className="absolute inset-0 overflow-hidden">
<div className='absolute inset-0 overflow-hidden'>
<iframe
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`}
className="absolute top-1/2 left-1/2 pointer-events-none"
allow="autoplay; encrypted-media"
className='absolute top-1/2 left-1/2 pointer-events-none'
allow='autoplay; encrypted-media'
style={{
border: 'none',
width: '100vw',
@@ -456,54 +511,65 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
) : (
/* 显示图片 */
<ProxyImage
originalSrc={getImageUrl(item.backdrop_path || item.poster_path)}
originalSrc={getImageUrl(
item.backdrop_path || item.poster_path
)}
alt={item.title}
className="absolute inset-0 w-full h-full object-cover"
className='absolute inset-0 w-full h-full object-cover'
loading={index === 0 ? 'eager' : 'lazy'}
/>
)}
{/* 渐变遮罩 */}
<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-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>
))}
</div>
{/* 内容信息 */}
<div className="absolute inset-0 flex items-end p-8 md:p-12 pointer-events-none">
<div className="max-w-2xl space-y-4">
<h2 className="text-3xl md:text-5xl font-bold text-white drop-shadow-lg">
<div className='absolute inset-0 flex items-end p-8 md:p-12 pointer-events-none'>
<div className='max-w-2xl space-y-4'>
<h2 className='text-3xl md:text-5xl font-bold text-white drop-shadow-lg'>
{currentItem.title}
</h2>
<div className="flex items-center gap-2 md:gap-3 text-sm md:text-base text-white/90 flex-wrap">
<div className='flex items-center gap-2 md:gap-3 text-sm md:text-base text-white/90 flex-wrap'>
{currentItem.vote_average > 0 && (
<span className="px-2 py-1 bg-yellow-500 text-black font-semibold rounded">
<span className='px-2 py-1 bg-yellow-500 text-black font-semibold rounded'>
{currentItem.vote_average.toFixed(1)}
</span>
)}
{/* 显示标签:优先TX的tags,其次豆瓣的genres,最后TMDB的genre_ids */}
{currentItem.tags && currentItem.tags.length > 0 ? (
currentItem.tags.slice(0, 3).map((tag, index) => (
<span key={index} className="px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm">
{tag}
</span>
))
) : currentItem.genres && Array.isArray(currentItem.genres) && currentItem.genres.length > 0 ? (
/* 显示豆瓣数据源的标签 */
currentItem.genres.slice(0, 3).map((genre, index) => (
<span key={index} className="px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm">
{genre}
</span>
))
) : (
/* 显示TMDB数据源的类型标签 */
getGenreNames(currentItem.genre_ids, 3).map(genre => (
<span key={genre} className="px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm">
{genre}
</span>
))
)}
{currentItem.tags && currentItem.tags.length > 0
? currentItem.tags.slice(0, 3).map((tag, index) => (
<span
key={index}
className='px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm'
>
{tag}
</span>
))
: currentItem.genres &&
Array.isArray(currentItem.genres) &&
currentItem.genres.length > 0
? /* 显示豆瓣数据源的标签 */
currentItem.genres.slice(0, 3).map((genre, index) => (
<span
key={index}
className='px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm'
>
{genre}
</span>
))
: /* 显示TMDB数据源的类型标签 */
getGenreNames(currentItem.genre_ids, 3).map((genre) => (
<span
key={genre}
className='px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm'
>
{genre}
</span>
))}
{currentItem.release_date && (
<span>{currentItem.release_date}</span>
)}
@@ -515,14 +581,14 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
e.stopPropagation();
handlePlay(currentItem.title);
}}
className="hidden md:flex items-center gap-2 px-6 py-3 bg-gray-500/30 hover:bg-gray-500/50 backdrop-blur-sm text-white font-semibold rounded-lg transition-all pointer-events-auto"
className='hidden md:flex items-center gap-2 px-6 py-3 bg-gray-500/30 hover:bg-gray-500/50 backdrop-blur-sm text-white font-semibold rounded-lg transition-all pointer-events-auto'
>
<Play className="w-5 h-5 fill-white" />
<Play className='w-5 h-5 fill-white' />
</button>
{currentItem.overview && (
<p className="text-sm md:text-base text-white/80 line-clamp-3 drop-shadow-md">
<p className='text-sm md:text-base text-white/80 line-clamp-3 drop-shadow-md'>
{currentItem.overview}
</p>
)}
@@ -532,36 +598,36 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
{/* 左右切换按钮 - 只在桌面端显示 */}
<button
onClick={goToPrevious}
className="hidden md:flex absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label="上一张"
className='hidden md:flex absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300'
aria-label='上一张'
>
<ChevronLeft className="w-8 h-8" />
<ChevronLeft className='w-8 h-8' />
</button>
<button
onClick={goToNext}
className="hidden md:flex absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label="下一张"
className='hidden md:flex absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300'
aria-label='下一张'
>
<ChevronRight className="w-8 h-8" />
<ChevronRight className='w-8 h-8' />
</button>
{/* 音量控制按钮 - 只在有豆瓣预告片时显示 */}
{currentItem.trailer_url && enableTrailers && (
<button
onClick={toggleMute}
className="absolute top-2 right-2 md:top-4 md:right-4 w-8 h-8 md:w-10 md:h-10 bg-black/30 hover:bg-black/60 text-white rounded-full flex items-center justify-center transition-all duration-300 z-10"
aria-label={isMuted ? "开启声音" : "关闭声音"}
className='absolute top-2 right-2 md:top-4 md:right-4 w-8 h-8 md:w-10 md:h-10 bg-black/30 hover:bg-black/60 text-white rounded-full flex items-center justify-center transition-all duration-300 z-10'
aria-label={isMuted ? '开启声音' : '关闭声音'}
>
{isMuted ? (
<VolumeX className="w-4 h-4 md:w-5 md:h-5" />
<VolumeX className='w-4 h-4 md:w-5 md:h-5' />
) : (
<Volume2 className="w-4 h-4 md:w-5 md:h-5" />
<Volume2 className='w-4 h-4 md:w-5 md:h-5' />
)}
</button>
)}
{/* 指示器 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
<div className='absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2'>
{items.map((_, index) => (
<button
key={index}