diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 27861cd..2e697f5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -190,6 +190,8 @@ export default async function RootLayout({ ENABLE_MOVIE_REQUEST: enableMovieRequest, WEB_LIVE_ENABLED: webLiveEnabled, CUSTOM_AD_FILTER_VERSION: customAdFilterVersion, + FESTIVE_EFFECT_ENABLED: + process.env.FESTIVE_EFFECT_ENABLED === 'true', }; return ( diff --git a/src/app/page.tsx b/src/app/page.tsx index fbc7325..142e4c3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -23,6 +23,7 @@ import VideoCard from '@/components/VideoCard'; import HttpWarningDialog from '@/components/HttpWarningDialog'; import BannerCarousel from '@/components/BannerCarousel'; import AIChatPanel from '@/components/AIChatPanel'; +import FireworksCanvas from '@/components/FireworksCanvas'; // 首页模块配置接口 interface HomeModule { @@ -542,6 +543,7 @@ function HomeClient() { return ( + {/* TMDB 热门轮播图 */}
@@ -551,40 +553,40 @@ function HomeClient() {
{/* 首页内容 */} <> - {/* 源站寻片和AI问片入口 */} -
- {/* 源站寻片入口 */} - {sourceSearchEnabled && ( - - - - )} - - {/* AI问片入口 */} - {aiEnabled && ( + {/* 源站寻片和AI问片入口 */} +
+ {/* 源站寻片入口 */} + {sourceSearchEnabled && ( + - )} -
+ + )} - {/* 继续观看 */} - + {/* AI问片入口 */} + {aiEnabled && ( + + )} +
- {/* 根据配置动态渲染首页模块 */} - {homeModules - .filter(module => module.enabled) - .sort((a, b) => a.order - b.order) - .map(module => renderModule(module.id))} + {/* 继续观看 */} + + + {/* 根据配置动态渲染首页模块 */} + {homeModules + .filter(module => module.enabled) + .sort((a, b) => a.order - b.order) + .map(module => renderModule(module.id))}
diff --git a/src/components/FireworksCanvas.tsx b/src/components/FireworksCanvas.tsx new file mode 100644 index 0000000..f1c6186 --- /dev/null +++ b/src/components/FireworksCanvas.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +type Rocket = { + x: number; + y: number; + speed: number; + targetY: number; + hue: number; +}; + +type Particle = { + x: number; + y: number; + vx: number; + vy: number; + life: number; + ttl: number; + hue: number; +}; + +const FireworksCanvas = () => { + const canvasRef = useRef(null); + const [active, setActive] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + const runtimeEnabled = + (window as any).RUNTIME_CONFIG?.FESTIVE_EFFECT_ENABLED === true; + + const start = new Date(2026, 1, 16, 0, 0, 0); + const end = new Date(2026, 2, 3, 23, 59, 59, 999); + const now = new Date(); + if (runtimeEnabled || (now >= start && now <= end)) { + setActive(true); + } + }, []); + + useEffect(() => { + if (!active) return; + const canvas = canvasRef.current; + if (!canvas) return; + + const context = canvas.getContext('2d'); + if (!context) return; + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + let width = 0; + let height = 0; + let dpr = window.devicePixelRatio || 1; + + const setSize = () => { + width = window.innerWidth; + height = window.innerHeight; + dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + context.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + + setSize(); + window.addEventListener('resize', setSize); + + const rockets: Rocket[] = []; + const particles: Particle[] = []; + let lastLaunchTime = 0; + + const launchRocket = () => { + rockets.push({ + x: Math.random() * width, + y: height + 24, + speed: 3 + Math.random() * 1.8, + targetY: height * (0.4 + Math.random() * 0.4), + hue: Math.random() * 360, + }); + }; + + const explode = (rocket: Rocket) => { + const count = 36 + Math.floor(Math.random() * 20); + for (let i = 0; i < count; i += 1) { + const angle = (Math.PI * 2 * i) / count + Math.random() * 0.2; + const speed = 1 + Math.random() * 1.8; + particles.push({ + x: rocket.x, + y: rocket.targetY, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life: 0, + ttl: 60 + Math.random() * 30, + hue: rocket.hue, + }); + } + }; + + let animationFrame = 0; + const animate = (time: number) => { + context.clearRect(0, 0, width, height); + + if (time - lastLaunchTime > 700 + Math.random() * 500) { + if (rockets.length < 4) launchRocket(); + lastLaunchTime = time; + } + + for (let i = rockets.length - 1; i >= 0; i -= 1) { + const rocket = rockets[i]; + const previousY = rocket.y; + rocket.y -= rocket.speed; + + context.strokeStyle = `hsla(${rocket.hue}, 100%, 70%, 0.85)`; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(rocket.x, previousY + 4); + context.lineTo(rocket.x, rocket.y); + context.stroke(); + + if (rocket.y <= rocket.targetY) { + rockets.splice(i, 1); + explode(rocket); + } + } + + for (let i = particles.length - 1; i >= 0; i -= 1) { + const particle = particles[i]; + particle.life += 1; + particle.x += particle.vx; + particle.y += particle.vy; + particle.vy += 0.03; + + const alpha = Math.max(0, 1 - particle.life / particle.ttl); + context.fillStyle = `hsla(${particle.hue}, 100%, 65%, ${alpha})`; + context.beginPath(); + context.arc(particle.x, particle.y, 2, 0, Math.PI * 2); + context.fill(); + + if (particle.life >= particle.ttl) { + particles.splice(i, 1); + } + } + + animationFrame = window.requestAnimationFrame(animate); + }; + + animationFrame = window.requestAnimationFrame(animate); + + return () => { + window.removeEventListener('resize', setSize); + window.cancelAnimationFrame(animationFrame); + }; + }, [active]); + + if (!active) return null; + + return ( +