forked from 77media/video-flow
443 lines
11 KiB
TypeScript
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;
|