Files
TiktokUI/components/VideoCard.tsx
hexin-codex-bot 2c7b81981c
Build and deploy Docker / deploy (push) Failing after 6m29s
fix: preserve continuous drawer and heart exit animations
2026-10-08 16:12:33 +08:00

82 lines
6.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}