video-flow-b/components/ui/video-carousel.tsx

443 lines
11 KiB
TypeScript

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<VideoCarouselProps> = ({ videos, width, height }) => {
const containerRef = useRef<HTMLUListElement>(null);
const [currentPlayingVideo, setCurrentPlayingVideo] = useState<HTMLVideoElement | null>(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 (
<Container $width={width} $height={height}>
<VideoList ref={containerRef}>
{paddedVideos.map((video, index) => (
<VideoItem
key={`${video.id}-${index}`}
aria-hidden={index < PADDING || index >= videos.length + PADDING}
>
<VideoWrapper>
<Video
muted={isMuted}
loop
playsInline
preload="none"
poster={`${video.url}?vframe/jpg/offset/1`}
src={video.url}
title={video.title}
/>
<ControlsWrapper>
<ControlButton
onClick={(e) => {
e.stopPropagation();
const videoEl = e.currentTarget.closest(VideoItem.toString())?.querySelector('video:not([data-reflection])') as HTMLVideoElement;
const reflectionEl = e.currentTarget.closest(VideoItem.toString())?.querySelector('video[data-reflection]') as HTMLVideoElement;
if (videoEl && reflectionEl) togglePlay(videoEl, reflectionEl);
}}
>
{isPlaying ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</ControlButton>
<ControlButton
onClick={(e) => {
e.stopPropagation();
const videoEl = e.currentTarget.closest(VideoItem.toString())?.querySelector('video:not([data-reflection])') as HTMLVideoElement;
if (videoEl) toggleMute(videoEl);
}}
>
{isMuted ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
)}
</ControlButton>
</ControlsWrapper>
<ReflectionWrapper>
<VideoReflection
muted
loop
playsInline
preload="none"
poster={`${video.url}?vframe/jpg/offset/1`}
src={video.url}
data-reflection="true"
/>
</ReflectionWrapper>
</VideoWrapper>
</VideoItem>
))}
</VideoList>
</Container>
);
};
export default VideoCarousel;