import React, { useRef, useEffect, useState } from 'react'; import styled, { keyframes } from 'styled-components'; interface Video { id: string; url: string; title: string; } interface VideoCarouselProps { videos: Video[]; width?: string; height?: string; } // 添加动画关键帧 const coverFlow = keyframes` 0% { z-index: 1; transform: translate3d(-80%, 0, 0) rotateY(-33deg); } 20% { scale: 1; transform: translate3d(0%, 0, 0) rotateY(-33deg); } 40% { transform: translate3d(15%, 0, 0) rotateY(-20deg); } /* Half way */ 50% { scale: 1.2; transform: translate3d(0, 0, 0) rotateY(0deg); } 60% { transform: translate3d(-15%, 0, 0) rotateY(20deg); } 80% { scale: 1; transform: translate3d(0%, 0, 0) rotateY(33deg); } 100% { z-index: 1; transform: translate3d(80%, 0, 0) rotateY(33deg); } `; const shrink = keyframes` 0% { scale: 0.75; opacity: 0; transform-origin: -150% 50%; } 5% { opacity: 1; } 10% { opacity: 1; scale: 1; transform-origin: 50% 50%; } 40% { scale: 1; opacity: 1; transform: translate3d(0, 0, 0vmin); } 50% { scale: 1.2; opacity: 1; transform: translate3d(0, 0, 15vmin); } 60% { scale: 1; opacity: 1; transform: translate3d(0, 0, 0vmin); } 90% { opacity: 1; transform-origin: 50% 50%; scale: 1; } 95% { opacity: 1; } 100% { scale: 0.75; opacity: 0; transform-origin: 250% 50%; } `; const z = keyframes` 0%, 100% { z-index: 1; } 50% { z-index: 100; } `; const Container = styled.div<{ $width?: string; $height?: string }>` display: grid; place-items: center; align-items: end; width: ${props => props.$width || '100vw'}; height: ${props => props.$height || '100vh'}; overflow: hidden; background: black; position: relative; `; const VideoList = styled.ul` width: 100%; height: 100%; display: flex; list-style-type: none; margin: 0; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; contain: inline-size; align-items: center; `; const VideoItem = styled.li` width: 30%; aspect-ratio: 16/9; display: grid; place-items: center; flex: 1 0 30cqw; position: relative; height: calc(30cqw * 9/16); width: 30cqw; view-timeline: --cover; view-timeline-axis: inline; transform-style: preserve-3d; perspective: 100vmin; background: transparent; animation: ${z} both linear; animation-timeline: --cover; animation-range: cover; &[aria-hidden=false] { scroll-snap-align: center; } &.center { z-index: 100; } `; const VideoWrapper = styled.div` height: 100%; width: 100%; aspect-ratio: 16/9; transform-origin: 100% 50%; transform-style: preserve-3d; perspective: 100vmin; position: absolute; top: 50%; left: 50%; translate: -50% -50%; will-change: transform; animation: ${shrink} both linear; animation-timeline: --cover; animation-range: cover; ${VideoItem}.center & { max-height: calc(150% + 2vmin); } `; const Video = styled.video` width: 100%; height: 100%; object-fit: cover; -webkit-box-reflect: below 2vmin linear-gradient(transparent 0 50%, hsl(0 0% 100% / 0.75) 100%); transform: translateZ(0); will-change: transform; animation: ${coverFlow} both linear; animation-timeline: --cover; animation-range: cover; border-radius: 1vmin; ${VideoItem}.center & { -webkit-box-reflect: unset; } `; const VideoReflection = styled(Video)` width: 100%; height: 100%; opacity: 0; transition: opacity 0.3s ease; mask-image: linear-gradient(to top, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%); scale: 1 !important; ${VideoItem}.center & { opacity: 0.75; scale: 1 !important; object-position: center bottom; } `; const ReflectionWrapper = styled.div` position: absolute; left: 0; margin-top: calc(6vmin * 1.2); width: 100%; height: calc((100vh - 4rem) / 8); pointer-events: none; overflow-y: hidden; ${VideoItem}.center & { scale: 1.19998; transform: scale(1, -1) translateZ(0) !important; } `; const ControlsWrapper = styled.div` position: absolute; bottom: -1rem; left: -1rem; display: flex; gap: 0.5rem; z-index: 1000; opacity: 0; transition: opacity 0.3s ease; ${VideoItem}.center:hover & { opacity: 1; } `; const ControlButton = styled.button` background: rgba(0, 0, 0, 0.6); border: none; border-radius: 50%; width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center; cursor: pointer; color: white; transition: background-color 0.3s ease; &:hover { background: rgba(0, 0, 0, 0.8); } svg { width: 0.8rem; height: 0.8rem; } `; const VideoCarousel: React.FC = ({ videos, width, height }) => { const containerRef = useRef(null); const [currentPlayingVideo, setCurrentPlayingVideo] = useState(null); const [isPlaying, setIsPlaying] = useState(true); const [isMuted, setIsMuted] = useState(true); const PADDING = 4; const togglePlay = (video: HTMLVideoElement, reflectionVideo: HTMLVideoElement) => { if (video.paused) { video.play(); reflectionVideo.play(); setIsPlaying(true); } else { video.pause(); reflectionVideo.pause(); setIsPlaying(false); } }; const toggleMute = (video: HTMLVideoElement) => { video.muted = !video.muted; setIsMuted(video.muted); }; const handleVideoPlayback = () => { if (!containerRef.current) return; requestAnimationFrame(() => { const containerWidth = containerRef.current!.offsetWidth; const containerCenter = containerWidth / 2; const items = containerRef.current!.querySelectorAll('li'); items.forEach(item => { const video = item.querySelector('video:not([data-reflection])') as HTMLVideoElement; const reflectionVideo = item.querySelector('video[data-reflection]') as HTMLVideoElement; if (!video || !reflectionVideo) return; const rect = item.getBoundingClientRect(); const itemCenter = rect.left + rect.width / 2; const isCenter = Math.abs(itemCenter - containerCenter) < rect.width / 3; item.classList.toggle('center', isCenter); if (isCenter) { if (currentPlayingVideo !== video) { if (currentPlayingVideo) { currentPlayingVideo.pause(); } video.play().catch(() => {}); reflectionVideo.play().catch(() => {}); setCurrentPlayingVideo(video); } } else { if (video !== currentPlayingVideo) { video.pause(); reflectionVideo.pause(); video.currentTime = 0; reflectionVideo.currentTime = 0; } } }); }); }; useEffect(() => { const container = containerRef.current; if (!container) return; let scrollBounds = { min: 0, max: 0 }; const setScrollBounds = () => { const items = container.children; if (items.length === 0) return; items[items.length - 1].scrollIntoView(); scrollBounds.max = container.scrollLeft + (items[0] as HTMLElement).offsetWidth; items[0].scrollIntoView(); scrollBounds.min = container.scrollLeft - (items[0] as HTMLElement).offsetWidth; }; const handleScroll = () => { if (container.scrollLeft < scrollBounds.min) { container.scrollLeft = scrollBounds.max; } else if (container.scrollLeft > scrollBounds.max) { container.scrollLeft = scrollBounds.min; } handleVideoPlayback(); }; setScrollBounds(); handleVideoPlayback(); container.addEventListener('scroll', handleScroll); window.addEventListener('resize', setScrollBounds); return () => { container.removeEventListener('scroll', handleScroll); window.removeEventListener('resize', setScrollBounds); }; }, []); const paddedVideos = [ ...videos.slice(-PADDING), ...videos, ...videos.slice(0, PADDING) ]; return ( {paddedVideos.map((video, index) => ( = videos.length + PADDING} > ))} ); }; export default VideoCarousel;