82 lines
6.9 KiB
TypeScript
82 lines
6.9 KiB
TypeScript
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||
import { Heart, MessageCircle, Bookmark, Share2, Plus, Play, Pause, Volume2, VolumeX, Music2, Maximize, Check, MapPin } from 'lucide-react';
|
||
import { compact, type VideoItem } from '../lib/videos';
|
||
|
||
export type Reaction = { liked?: boolean; saved?: boolean; followed?: boolean };
|
||
type Props = {
|
||
item: VideoItem; position: number; active: boolean; muted: boolean; playing: boolean;
|
||
reaction: Reaction; commentCount: number;
|
||
setPlaying: (value: boolean) => void; toggleMuted: () => void;
|
||
onReact: (field: keyof Reaction, force?: boolean) => void;
|
||
onComment: () => void; onShare: () => void; onTag: (tag: string) => void;
|
||
};
|
||
|
||
export function VideoCard({ item, position, active, muted, playing, reaction, commentCount, setPlaying, toggleMuted, onReact, onComment, onShare, onTag }: Props) {
|
||
const video = useRef<HTMLVideoElement>(null);
|
||
const card = useRef<HTMLElement>(null);
|
||
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const [progress, setProgress] = useState(0);
|
||
const [duration, setDuration] = useState(8);
|
||
const [failed, setFailed] = useState(false);
|
||
const [hearts, setHearts] = useState<{ id: number; x: number; y: number }[]>([]);
|
||
|
||
useEffect(() => {
|
||
const element = video.current;
|
||
if (!element) return;
|
||
let cancelled = false;
|
||
if (active && playing) element.play().catch(() => { if (!cancelled) setPlaying(false); });
|
||
else element.pause();
|
||
if (!active) { element.currentTime = 0; setProgress(0); }
|
||
return () => { cancelled = true; };
|
||
}, [active, playing, setPlaying]);
|
||
useEffect(() => () => { if (clickTimer.current) clearTimeout(clickTimer.current); }, []);
|
||
|
||
const time = (seconds: number) => `00:${Math.floor(seconds).toString().padStart(2, '0')}`;
|
||
return <article ref={card} className={`video-page scene-${item.id} ${active ? 'is-active' : ''}`}
|
||
style={{ transform: `translate3d(0, ${position * 100}%, 0)` }} aria-hidden={!active} inert={!active} aria-label={item.title}>
|
||
<video ref={video} className="video" src={item.src} poster={item.poster} loop playsInline
|
||
muted={!active || muted} preload="auto" onTimeUpdate={event => { if (active) setProgress(event.currentTarget.currentTime); }}
|
||
onLoadedMetadata={event => setDuration(event.currentTarget.duration)} onError={() => setFailed(true)} />
|
||
<div className="scrim" />
|
||
<button className="video-hit" aria-label={playing ? '暂停视频' : '播放视频'}
|
||
onClick={() => { if (clickTimer.current) clearTimeout(clickTimer.current); clickTimer.current = setTimeout(() => setPlaying(!playing), 220); }}
|
||
onDoubleClick={event => {
|
||
if (clickTimer.current) clearTimeout(clickTimer.current);
|
||
onReact('liked', true);
|
||
const bounds = card.current!.getBoundingClientRect();
|
||
setHearts(previous => [...previous.slice(-4), { id: Date.now(), x: event.clientX - bounds.left, y: event.clientY - bounds.top }]);
|
||
}} />
|
||
<div className="scene-copy" aria-hidden="true"><span>{item.label}</span><strong>{item.subtitle}</strong><i>THE LITTLE THINGS. THE BIG FEELINGS.</i></div>
|
||
{active && !playing && !failed && <span className="paused"><Play size={58} fill="currentColor" /></span>}
|
||
{hearts.map(heart => <span key={heart.id} className="heart-burst" style={{ left: heart.x, top: heart.y }} onAnimationEnd={event => { if (event.target === event.currentTarget) setHearts(previous => previous.filter(h => h.id !== heart.id)); }}><Heart size={112} fill="currentColor" /><i /><i /><i /><i /></span>)}
|
||
{failed && <div className="video-error">视频暂时无法加载<button onClick={() => { setFailed(false); video.current?.load(); setPlaying(true); }}>重新加载</button></div>}
|
||
<div className="video-info"><span className="location"><MapPin size={13} />{item.location}<span>·</span>精选风景</span>
|
||
<h2>@{item.author}<span className="verified"><Check size={11} /></span></h2>
|
||
<h3>{item.title}</h3><p>{item.description}</p>
|
||
<div className="tags">{item.tags.map(tag => <button key={tag} onClick={() => onTag(tag)}>#{tag}</button>)}</div>
|
||
<div className="music"><Music2 size={15} /><span>{item.music}</span><span className={`music-bars ${active && playing ? 'playing' : ''}`}><i /><i /><i /><i /></span></div>
|
||
</div>
|
||
<div className="actions">
|
||
<button className={`avatar ${reaction.followed ? 'following' : ''}`} style={{ background: item.color }} aria-label={reaction.followed ? '取消关注作者' : '关注作者'} aria-pressed={!!reaction.followed} onClick={() => onReact('followed')}>
|
||
{item.avatar}<span key={String(reaction.followed)}>{reaction.followed ? <Check size={13} /> : <Plus size={13} />}</span>
|
||
</button>
|
||
<button className={`action ${reaction.liked ? 'liked' : ''}`} aria-label="点赞" aria-pressed={!!reaction.liked} onClick={() => onReact('liked')}><Heart fill="currentColor" /><span key={String(reaction.liked)}>{compact(item.likes + (reaction.liked ? 1 : 0))}</span></button>
|
||
<button className="action" aria-label="查看评论" onClick={onComment}><MessageCircle fill="currentColor" /><span>{item.comments + commentCount}</span></button>
|
||
<button className={`action ${reaction.saved ? 'saved' : ''}`} aria-label="收藏" aria-pressed={!!reaction.saved} onClick={() => onReact('saved')}><Bookmark fill="currentColor" /><span key={String(reaction.saved)}>{compact(item.saves + (reaction.saved ? 1 : 0))}</span></button>
|
||
<button className="action" aria-label="分享视频" onClick={onShare}><Share2 fill="currentColor" /><span>分享</span></button>
|
||
<span className={`record ${active && playing ? 'spinning' : ''}`}><span style={{ background: item.color }}><Music2 size={17} /></span></span>
|
||
</div>
|
||
<div className="player-controls">
|
||
<input className="progress" type="range" aria-label="播放进度" min="0" max={duration || 8} step="0.05" value={progress}
|
||
style={{ '--progress': `${progress / (duration || 8) * 100}%` } as CSSProperties}
|
||
onChange={event => { const value = Number(event.target.value); if (video.current) video.current.currentTime = value; setProgress(value); }} />
|
||
<div className="control-row"><button aria-label={playing ? '暂停' : '播放'} onClick={() => setPlaying(!playing)}>{playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}</button>
|
||
<span className="time">{time(progress)} <span>/ {time(duration)}</span></span><span className="control-hint">上下拖动或滚动,发现下一个瞬间</span>
|
||
<span className="quality">高清 <small>HD</small></span>
|
||
<button aria-label={muted ? '开启声音' : '静音'} onClick={toggleMuted}>{muted ? <VolumeX size={20} /> : <Volume2 size={20} />}</button>
|
||
<button aria-label="全屏" onClick={() => { if (document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen?.().catch(() => {}); }}><Maximize size={19} /></button>
|
||
</div>
|
||
</div>
|
||
</article>;
|
||
}
|