feat: add true gesture-driven full-viewport video feed and polished motion

This commit is contained in:
2026-10-08 16:09:57 +08:00
parent 693990d9fb
commit c0b752da3a
9 changed files with 449 additions and 53 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm typecheck && pnpm build
RUN pnpm typecheck && pnpm test && pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
+5 -2
View File
@@ -12,11 +12,14 @@ pnpm install --frozen-lockfile
pnpm dev
```
浏览器打开 http://localhost:3000 。手机与电脑自适应:手机全屏触摸上下切换,电脑宽屏播放,支持滚轮、上下方向键及右侧切换按钮。空格暂停,M 切换声音。
浏览器打开 http://localhost:3000 。页面直接铺满浏览器宽度,没有侧栏或固定宽度的播放器外框。手机保留底部导航,电脑使用浮层控制。
上下拖动时,上下两条视频会随鼠标或手指一起移动;轻拖后松开回弹,超过约 19% 屏幕高度或快速甩动则翻页。三条真实页面循环复用,避免切换时闪屏。电脑也支持滚轮、上下方向键及右侧切换按钮。空格暂停,M 切换声音,斜杠搜索。
## 交互
- 三段本地 MP4 无限循环切换,单击暂停、双击点赞、拖动进度、静音/开启声音、桌面全屏。
- 三段本地 MP4 无限循环切换,单击暂停、双击位置爱心与粒子、拖动进度、静音/开启声音、桌面全屏。
- 点赞收藏弹性反馈,评论面板滑入滑出,手机评论面板可拖动顶部把手向下关闭;系统减少动态效果设置受到尊重。
- 点赞、收藏、关注按视频保存在本机 localStorage,无需账号。
- 搜索默认数据、标签搜索、分享视频专属链接、评论抽屉及本次会话评论。
- 其他导航提供演示提示,不包含其他业务页面。
+119
View File
@@ -0,0 +1,119 @@
import { Children, forwardRef, useEffect, useImperativeHandle, useRef, type ReactNode, type PointerEvent, type WheelEvent } from 'react';
import { flushSync } from 'react-dom';
import { swipeDirection } from '../lib/gestures';
export type SwipeFeedHandle = { next: (direction: number) => void };
type Props = { children: ReactNode; onChange: (direction: number) => void; disabled: boolean };
/** Three live pages share one moving track, so adjacent videos stay visible during a gesture. */
export const SwipeFeed = forwardRef<SwipeFeedHandle, Props>(function SwipeFeed({ children, onChange, disabled }, ref) {
const viewport = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(null);
const gesture = useRef({ active: false, startY: 0, lastY: 0, lastTime: 0, velocity: 0, moved: false });
const offset = useRef(0);
const settling = useRef(false);
const suppressClickUntil = useRef(0);
const wheelTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const wheelCooldown = useRef(0);
const finishTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (wheelTimer.current) clearTimeout(wheelTimer.current);
if (finishTimer.current) clearTimeout(finishTimer.current);
}, []);
function move(y: number) {
const height = viewport.current?.clientHeight || 1;
offset.current = Math.max(-height, Math.min(height, y));
if (track.current) track.current.style.transform = `translate3d(0, ${offset.current}px, 0)`;
}
function settle(direction: number) {
const node = track.current;
if (!node || settling.current) return;
settling.current = true;
gesture.current.active = false;
const height = viewport.current?.clientHeight || 1;
const target = -direction * height;
const distance = Math.abs(target - offset.current);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const duration = reduced ? 0 : Math.min(460, Math.max(240, distance / height * 420));
node.style.transition = `transform ${duration}ms cubic-bezier(.22,.72,.18,1)`;
// Flush the drag position before applying the landing position.
node.getBoundingClientRect();
move(target);
viewport.current?.setAttribute('data-moving', 'true');
const finish = () => {
node.removeEventListener('transitionend', ended);
if (finishTimer.current) clearTimeout(finishTimer.current);
node.style.transition = 'none';
// Update slide positions and reset the shared track in the same paint.
if (direction) flushSync(() => onChange(direction));
move(0);
settling.current = false;
wheelCooldown.current = performance.now() + 160;
viewport.current?.removeAttribute('data-moving');
};
const ended = (event: TransitionEvent) => { if (event.target === node) finish(); };
node.addEventListener('transitionend', ended);
finishTimer.current = setTimeout(finish, duration + 50);
}
useImperativeHandle(ref, () => ({ next(direction) { if (!disabled) settle(Math.sign(direction)); } }));
function pointerDown(event: PointerEvent<HTMLDivElement>) {
if (disabled || settling.current || event.button !== 0 || (event.target as HTMLElement).closest('button:not(.video-hit),input,a')) return;
if (wheelTimer.current) clearTimeout(wheelTimer.current);
gesture.current = { active: true, startY: event.clientY - offset.current, lastY: event.clientY, lastTime: performance.now(), velocity: 0, moved: false };
(event.target as HTMLElement).setPointerCapture(event.pointerId);
}
function pointerMove(event: PointerEvent<HTMLDivElement>) {
const state = gesture.current;
if (!state.active) return;
const now = performance.now();
const elapsed = now - state.lastTime;
if (elapsed > 0) state.velocity = state.velocity * .35 + (event.clientY - state.lastY) / elapsed * .65;
state.lastY = event.clientY;
state.lastTime = now;
const dy = event.clientY - state.startY;
if (Math.abs(dy) > 5) state.moved = true;
if (state.moved) {
event.preventDefault();
suppressClickUntil.current = performance.now() + 350;
viewport.current?.setAttribute('data-moving', 'true');
move(dy);
}
}
function pointerUp(event: PointerEvent<HTMLDivElement>) {
const state = gesture.current;
if (!state.active) return;
state.active = false;
if (!state.moved) return;
event.preventDefault();
suppressClickUntil.current = performance.now() + 400;
const height = viewport.current?.clientHeight || 1;
const velocity = performance.now() - state.lastTime < 100 ? state.velocity : 0;
settle(swipeDirection(offset.current, velocity, height));
}
function wheel(event: WheelEvent<HTMLDivElement>) {
if (disabled || event.ctrlKey || Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
if (settling.current || performance.now() < wheelCooldown.current) return;
const unit = event.deltaMode === 1 ? 20 : event.deltaMode === 2 ? (viewport.current?.clientHeight || 800) : 1;
viewport.current?.setAttribute('data-moving', 'true');
move(offset.current - event.deltaY * unit);
if (wheelTimer.current) clearTimeout(wheelTimer.current);
wheelTimer.current = setTimeout(() => settle(Math.abs(offset.current) > 35 ? (offset.current < 0 ? 1 : -1) : 0), 90);
}
return <div ref={viewport} className="feed-viewport" aria-label="上下滑动视频流"
onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerUp}
onPointerCancel={() => { if (gesture.current.active) { gesture.current.active = false; settle(0); } }}
onWheel={wheel}
onClickCapture={event => { if (performance.now() < suppressClickUntil.current || settling.current) { event.preventDefault(); event.stopPropagation(); } }}>
<div ref={track} className="feed-track">{Children.toArray(children)}</div>
</div>;
});
+81
View File
@@ -0,0 +1,81 @@
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={() => 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>;
}
+12
View File
@@ -0,0 +1,12 @@
/** Positive movement means pulling the previous page down; negative means pushing to the next. */
export function swipeDirection(distance: number, velocity: number, height: number): -1 | 0 | 1 {
if (height <= 0) return 0;
const committed = Math.abs(distance) > height * .19 || (Math.abs(velocity) > .4 && Math.abs(distance) > 20);
return committed ? (distance < 0 ? 1 : -1) : 0;
}
/** Three unique videos are kept mounted and repositioned around the active page. */
export function pagePosition(videoIndex: number, activeIndex: number): -1 | 0 | 1 {
const relative = (videoIndex - activeIndex + 3) % 3;
return relative === 2 ? -1 : relative as 0 | 1;
}
+1
View File
@@ -7,6 +7,7 @@
"build": "next build",
"start": "next start --hostname 0.0.0.0",
"typecheck": "tsc --noEmit",
"test": "node --test tests/gestures.test.mjs",
"generate:videos": "node scripts/generate-videos.mjs"
},
"dependencies": {
+155 -43
View File
@@ -1,46 +1,158 @@
import Head from 'next/head';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Heart, MessageCircle, Bookmark, Share2, Plus, Play, Pause, Volume2, VolumeX, ChevronUp, ChevronDown, Search, Home, Compass, Users, Music2, Maximize, X, Check, MapPin, Send, ArrowUpRight, UserRound } from 'lucide-react';
import { videos, compact } from '../lib/videos';
type Reaction = { liked?:boolean; saved?:boolean; followed?:boolean };
type Comment = {name:string; text:string};
const initialComments:Comment[]=[{name:'小岛同学',text:'这一刻,连时间都慢下来了。'},{name:'今天也要开心',text:'好喜欢这个氛围 ✨'},{name:'路过人间',text:'收藏了,留给忙碌的一天。'}];
export default function Feed(){
const [index,setIndex]=useState(0),[playing,setPlaying]=useState(true),[muted,setMuted]=useState(true),[progress,setProgress]=useState(0),[duration,setDuration]=useState(8),[reactions,setReactions]=useState<Record<string,Reaction>>({}),[ready,setReady]=useState(false),[panel,setPanel]=useState<'comments'|'search'|null>(null),[toast,setToast]=useState(''),[burst,setBurst]=useState(false),[query,setQuery]=useState(''),[draft,setDraft]=useState(''),[comments,setComments]=useState<Record<string,Comment[]>>({}),[tab,setTab]=useState('推荐'),[error,setError]=useState(false);
const video=useRef<HTMLVideoElement>(null),stage=useRef<HTMLElement>(null),touch=useRef({x:0,y:0}),lastScroll=useRef(0),clickTimer=useRef<ReturnType<typeof setTimeout>|null>(null),toastTimer=useRef<ReturnType<typeof setTimeout>|null>(null),burstTimer=useRef<ReturnType<typeof setTimeout>|null>(null);
const item=videos[index],reaction=reactions[item.id]||{};
const notify=useCallback((text:string)=>{setToast(text);if(toastTimer.current)clearTimeout(toastTimer.current);toastTimer.current=setTimeout(()=>setToast(''),2400);},[]);
useEffect(()=>{try{const data=JSON.parse(localStorage.getItem('flow-reactions')||'{}');if(data && typeof data==='object' && !Array.isArray(data))setReactions(data);}catch{}setReady(true);const id=new URLSearchParams(location.search).get('video');const found=videos.findIndex(v=>v.id===id);if(found>=0)setIndex(found);return()=>{if(clickTimer.current)clearTimeout(clickTimer.current);if(toastTimer.current)clearTimeout(toastTimer.current);if(burstTimer.current)clearTimeout(burstTimer.current);};},[]);
useEffect(()=>{if(ready)try{localStorage.setItem('flow-reactions',JSON.stringify(reactions));}catch{}},[reactions,ready]);
const next=useCallback((direction:number)=>{setIndex(i=>(i+direction+videos.length)%videos.length);setProgress(0);setPlaying(true);setError(false);setBurst(false);setDraft('');if(clickTimer.current)clearTimeout(clickTimer.current);},[]);
useEffect(()=>{const el=video.current;if(!el)return;if(playing){el.play().catch(()=>setPlaying(false));}else el.pause();},[index,playing]);
useEffect(()=>{function key(e:KeyboardEvent){if((e.target as HTMLElement).closest('input,textarea,button')||panel)return;if(e.code==='ArrowDown'||e.code==='ArrowUp'){e.preventDefault();next(e.code==='ArrowDown'?1:-1);}if(e.code==='Space'){e.preventDefault();setPlaying(p=>!p);}if(e.code==='KeyM')setMuted(m=>!m);}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[next,panel]);
useEffect(()=>{const el=stage.current;if(!el)return;const wheel=(e:WheelEvent)=>{if(panel||Math.abs(e.deltaY)<12||e.ctrlKey)return;e.preventDefault();if(Date.now()-lastScroll.current<750)return;lastScroll.current=Date.now();next(e.deltaY>0?1:-1);};el.addEventListener('wheel',wheel,{passive:false});return()=>el.removeEventListener('wheel',wheel);},[next,panel]);
useEffect(()=>{if(!panel)return;const before=document.activeElement as HTMLElement;const dialog=document.querySelector<HTMLElement>('[role="dialog"]');dialog?.querySelector<HTMLElement>('button,input')?.focus();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')setPanel(null);if(e.key==='Tab'){const list=dialog?.querySelectorAll<HTMLElement>('button,input,[tabindex="0"]');if(!list?.length)return;const first=list[0],last=list[list.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);before?.focus();};},[panel]);
function toggle(field:keyof Reaction){setReactions(r=>({...r,[item.id]:{...r[item.id],[field]:!r[item.id]?.[field]}}));}
function like(){setReactions(r=>({...r,[item.id]:{...r[item.id],liked:true}}));setBurst(true);if(burstTimer.current)clearTimeout(burstTimer.current);burstTimer.current=setTimeout(()=>setBurst(false),800);}
async function share(){const url=`${location.origin}/?video=${item.id}`;try{if(navigator.share){await navigator.share({title:item.title,url});}else{await navigator.clipboard.writeText(url);notify('视频链接已复制');}}catch(e){if((e as Error).name!=='AbortError')notify('请复制地址栏链接进行分享');}}
const time=(s:number)=>`00:${Math.floor(s).toString().padStart(2,'0')}`;
const results=videos.filter(v=>`${v.title}${v.author}${v.tags.join('')}`.includes(query.trim()));
return <><Head><title>瞬间 FLOW · 发现生活的另一面</title><meta name="description" content="沉浸式短视频体验,记录每一个值得停留的瞬间。"/><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/><meta name="theme-color" content="#0b0b0e"/></Head>
<div className="app-shell">
<aside className="sidebar"><a className="brand" href="/" aria-label="瞬间首页"><span className="brand-mark"><Music2 size={28}/></span>瞬间<span className="brand-en">FLOW</span></a><button className="search-box" onClick={()=>setPanel('search')}><Search size={19}/><span>搜索感兴趣的内容</span><kbd>/</kbd></button><nav>{[{name:'推荐',icon:Home},{name:'发现',icon:Compass},{name:'关注',icon:Users}].map(({name,icon:Icon})=><button key={name} className={`nav-item ${tab===name?'active':''}`} onClick={()=>{setTab(name);if(name==='发现')setPanel('search');if(name==='关注')notify('关注作者后,关注标记会保存在此设备');}}><Icon size={23}/>{name}{name==='推荐'&&<span className="active-dot"/>}</button>)}</nav><div className="sidebar-line"/><p className="sidebar-label">精选频道</p>{['生活的浪漫','自然放映厅','城市漫游'].map((label,i)=><button key={label} className="channel" onClick={()=>{setIndex(i);setProgress(0);setPlaying(true);setError(false);}}><span className={`channel-dot dot-${i}`}/>{label}<ArrowUpRight size={14}/></button>)}<div className="sidebar-bottom"><div className="mini-brand">每一刻,都值得被看见<span>MAKE EVERY MOMENT COUNT.</span></div><p>关于瞬间 · 使用指南 · 联系我们</p><p>© 2026 瞬间 FLOW</p><span className="demo-pill"><span/>本地演示 · 无需登录</span></div></aside>
<main className="main"><header className="desktop-header"><div><span className="eyebrow">FOR YOU</span><h1>发现生活的另一面<span>精选推荐</span></h1></div><span className="header-note"><span/> 偶遇一个好瞬间</span></header>
<section ref={stage} className={`stage scene-${item.id}`} aria-label="短视频播放器" onTouchStart={e=>{touch.current={x:e.touches[0].clientX,y:e.touches[0].clientY};}} onTouchEnd={e=>{if(panel||(e.target as HTMLElement).closest('button:not(.video-hit),input'))return;const dy=e.changedTouches[0].clientY-touch.current.y,dx=e.changedTouches[0].clientX-touch.current.x;if(Math.abs(dy)>55&&Math.abs(dy)>Math.abs(dx)){e.preventDefault();next(dy<0?1:-1);}}}>
<video key={item.id} ref={video} className="video" src={item.src} poster={item.poster} loop playsInline muted={muted} autoPlay preload="auto" onTimeUpdate={e=>setProgress(e.currentTarget.currentTime)} onLoadedMetadata={e=>setDuration(e.currentTarget.duration)} onPlay={()=>setPlaying(true)} onPause={()=>setPlaying(false)} onError={()=>{setError(true);setPlaying(false);}}/>
<div className="scrim"/><button className="video-hit" aria-label={playing?'暂停视频':'播放视频'} onClick={()=>{if(clickTimer.current)clearTimeout(clickTimer.current);clickTimer.current=setTimeout(()=>setPlaying(p=>!p),220);}} onDoubleClick={()=>{if(clickTimer.current)clearTimeout(clickTimer.current);like();}}/>
<div className="mobile-top"><button aria-label={muted?'开启声音':'静音'} onClick={()=>setMuted(m=>!m)}>{muted?<VolumeX size={23}/>:<Volume2 size={23}/>}</button><div><button onClick={()=>notify('点击作者头像即可关注')}>关注</button><button className="selected" onClick={()=>setTab('推荐')}>推荐</button></div><button aria-label="搜索视频" onClick={()=>setPanel('search')}><Search size={24}/></button></div>
<div className="stage-top"><span className="curated"><span/> 今日精选</span><span className="location"><MapPin size={14}/>{item.location}</span><button aria-label={muted?'开启声音':'静音'} className="sound-top" onClick={()=>setMuted(m=>!m)}>{muted?<VolumeX size={19}/>:<Volume2 size={19}/>}</button></div>
<div className="scene-copy" aria-hidden="true"><span>{item.label}</span><strong>{item.subtitle}</strong><i>THE LITTLE THINGS. THE BIG FEELINGS.</i></div>
{!playing&&!error&&<span className="paused"><Play size={54} fill="currentColor"/></span>}{burst&&<span className="heart-burst"><Heart size={110} fill="currentColor"/></span>}{error&&<div className="video-error">视频暂时无法加载<button onClick={()=>{setError(false);video.current?.load();setPlaying(true);}}>重新加载</button></div>}
<div className="video-info"><span className="editor-label">瞬间精选 <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(t=><button key={t} onClick={()=>{setQuery(t);setPanel('search');}}>#{t}</button>)}</div><div className="music"><Music2 size={15}/><span>{item.music}</span><span className="music-bars">▂ ▅ ▃ ▆ ▂</span></div></div>
<div className="actions"><button className="avatar" style={{background:item.color}} aria-label={reaction.followed?'取消关注作者':'关注作者'} aria-pressed={!!reaction.followed} onClick={()=>toggle('followed')}>{item.avatar}<span>{reaction.followed?<Check size={13}/>:<Plus size={13}/>}</span></button><button className={`action ${reaction.liked?'liked':''}`} aria-label="点赞" aria-pressed={!!reaction.liked} onClick={()=>toggle('liked')}><Heart fill="currentColor"/><span>{compact(item.likes+(reaction.liked?1:0))}</span></button><button className="action" aria-label="查看评论" onClick={()=>setPanel('comments')}><MessageCircle fill="currentColor"/><span>{item.comments+(comments[item.id]?.length||0)}</span></button><button className={`action ${reaction.saved?'saved':''}`} aria-label="收藏" aria-pressed={!!reaction.saved} onClick={()=>toggle('saved')}><Bookmark fill="currentColor"/><span>{compact(item.saves+(reaction.saved?1:0))}</span></button><button className="action" aria-label="分享视频" onClick={share}><Share2 fill="currentColor"/><span>分享</span></button><span className={`record ${playing?'spinning':''}`}><span style={{background:item.color}}><Music2 size={16}/></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 React.CSSProperties} onChange={e=>{const value=Number(e.target.value);if(video.current)video.current.currentTime=value;setProgress(value);}}/><div className="control-row"><button aria-label={playing?'暂停':'播放'} onClick={()=>setPlaying(p=>!p)}>{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><button className="quality" onClick={()=>notify('当前为本地生成的高清演示视频')}>高清 <span>HD</span></button><button aria-label={muted?'开启声音':'静音'} onClick={()=>setMuted(m=>!m)}>{muted?<VolumeX size={20}/>:<Volume2 size={20}/>}</button><button aria-label="全屏" onClick={()=>{if(document.fullscreenElement)document.exitFullscreen();else stage.current?.requestFullscreen?.().catch(()=>notify('此浏览器暂不支持全屏'));}}><Maximize size={18}/></button></div></div>
</section><div className="desktop-footer"><span><span className="key">↑</span><span className="key">↓</span> 切换视频 <span className="footer-divider"/> <span className="key space">Space</span> 暂停 / 播放</span><span><span className="current-number">0{index+1}</span> / 0{videos.length} <span className="footer-divider"/> 好风景,循环看</span></div>
<div className="next-controls"><button aria-label="上一个视频" onClick={()=>next(-1)}><ChevronUp/></button><span/ ><button aria-label="下一个视频" onClick={()=>next(1)}><ChevronDown/></button></div>
</main><nav className="mobile-bottom"><button className="selected" onClick={()=>{setTab('推荐');setPanel(null);}}><Home fill="currentColor"/>首页</button><button onClick={()=>setPanel('search')}><Compass/>发现</button><button className="create" aria-label="发布提示" onClick={()=>notify('当前为浏览演示,暂未开放发布')}><Plus/></button><button onClick={()=>setPanel('comments')}><MessageCircle/>消息</button><button onClick={()=>notify('无需登录,点赞与收藏保存在此设备')}><UserRound/>我</button></nav>
</div>
{panel&&<div className="modal-backdrop" onClick={()=>setPanel(null)}><section className="panel" role="dialog" aria-modal="true" aria-labelledby="panel-title" onClick={e=>e.stopPropagation()}><header><h2 id="panel-title">{panel==='comments'?`评论 · ${item.comments+(comments[item.id]?.length||0)}`:'发现好瞬间'}</h2><button aria-label="关闭" onClick={()=>setPanel(null)}><X/></button></header>{panel==='comments'?<><p className="panel-note">演示评论 · 新评论仅在本次浏览中保留</p><div className="comments">{[...initialComments,...comments[item.id]||[]].map((c,i)=><article key={i}><span className="comment-avatar">{c.name[0]}</span><div><strong>{c.name}</strong><p>{c.text}</p><small>刚刚 · 回复</small></div><Heart size={15}/></article>)}</div><form onSubmit={e=>{e.preventDefault();if(!draft.trim())return;setComments(c=>({...c,[item.id]:[...c[item.id]||[],{name:'访客',text:draft.trim()}]}));setDraft('');}}><input aria-label="评论内容" placeholder="留下你的心情…" value={draft} maxLength={200} onChange={e=>setDraft(e.target.value)}/><button aria-label="发送评论" disabled={!draft.trim()}><Send size={20}/></button></form></>:<><div className="search-input"><Search size={20}/><input aria-label="搜索内容" placeholder="搜索标题、作者或标签" value={query} onChange={e=>setQuery(e.target.value)}/></div><div className="search-results">{results.map(v=><button key={v.id} onClick={()=>{setIndex(videos.findIndex(x=>x.id===v.id));setPlaying(true);setProgress(0);setError(false);setPanel(null);}}><img src={v.poster} alt=""/><span><strong>{v.title}</strong><small>@{v.author} · {v.tags[0]}</small></span><Play size={18}/></button>)}{results.length===0&&<p>还没有找到,试试“日落”或“海”。</p>}</div></>}</section></div>}
<div className={`toast ${toast?'visible':''}`} role="status">{toast}</div>
</>;
import { ChevronUp, ChevronDown, Search, Home, Compass, Music2, X, Send, UserRound, Plus, MessageCircle, Heart, Play, VolumeX, Volume2 } from 'lucide-react';
import { videos } from '../lib/videos';
import { SwipeFeed, type SwipeFeedHandle } from '../components/SwipeFeed';
import { VideoCard, type Reaction } from '../components/VideoCard';
import { pagePosition } from '../lib/gestures';
type Comment = { name: string; text: string };
const initialComments: Comment[] = [
{ name: '小岛同学', text: '这一刻,连时间都慢下来了。' },
{ name: '今天也要开心', text: '好喜欢这个氛围 ✨' },
{ name: '路过人间', text: '收藏了,留给忙碌的一天。' },
];
export default function Feed() {
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(true);
const [muted, setMuted] = useState(true);
const [reactions, setReactions] = useState<Record<string, Reaction>>({});
const [ready, setReady] = useState(false);
const [panel, setPanel] = useState<'comments' | 'search' | 'share' | null>(null);
const [closing, setClosing] = useState(false);
const [toast, setToast] = useState('');
const [query, setQuery] = useState('');
const [draft, setDraft] = useState('');
const [comments, setComments] = useState<Record<string, Comment[]>>({});
const [shareUrl, setShareUrl] = useState('');
const feed = useRef<SwipeFeedHandle>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const drawer = useRef<HTMLElement>(null);
const sheetGesture = useRef<{ startY: number; dy: number } | null>(null);
const item = videos[index];
const notify = useCallback((text: string) => {
setToast(text);
if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(''), 2400);
}, []);
const closePanel = useCallback(() => {
setClosing(true);
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => { setPanel(null); setClosing(false); }, 240);
}, []);
useEffect(() => {
try {
const data = JSON.parse(localStorage.getItem('flow-reactions') || '{}');
if (data && typeof data === 'object' && !Array.isArray(data)) setReactions(data);
} catch {}
setReady(true);
const id = new URLSearchParams(location.search).get('video');
const found = videos.findIndex(video => video.id === id);
if (found >= 0) setIndex(found);
return () => {
if (toastTimer.current) clearTimeout(toastTimer.current);
if (closeTimer.current) clearTimeout(closeTimer.current);
};
}, []);
useEffect(() => {
if (ready) try { localStorage.setItem('flow-reactions', JSON.stringify(reactions)); } catch {}
}, [reactions, ready]);
const change = useCallback((direction: number) => {
setIndex(value => (value + direction + videos.length) % videos.length);
setPlaying(true);
setDraft('');
}, []);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if ((event.target as HTMLElement).closest('input,textarea') || panel) return;
if (event.code === 'ArrowDown' || event.code === 'ArrowUp') {
event.preventDefault(); feed.current?.next(event.code === 'ArrowDown' ? 1 : -1);
}
if (event.code === 'Space' && !(event.target as HTMLElement).closest('button')) { event.preventDefault(); setPlaying(value => !value); }
if (event.code === 'KeyM') setMuted(value => !value);
if (event.code === 'Slash') { event.preventDefault(); setPanel('search'); }
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [panel]);
useEffect(() => {
if (!panel) return;
const before = document.activeElement as HTMLElement;
const element = drawer.current;
element?.querySelector<HTMLElement>('button,input')?.focus();
const key = (event: KeyboardEvent) => {
if (event.key === 'Escape') closePanel();
if (event.key !== 'Tab') return;
const elements = element?.querySelectorAll<HTMLElement>('button:not(:disabled),input,[tabindex="0"]');
if (!elements?.length) return;
const first = elements[0], last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', key);
return () => { document.removeEventListener('keydown', key); before?.focus(); };
}, [panel, closePanel]);
function react(id: string, field: keyof Reaction, force?: boolean) {
setReactions(previous => ({ ...previous, [id]: { ...previous[id], [field]: force ?? !previous[id]?.[field] } }));
}
async function share() {
const url = `${location.origin}/?video=${item.id}`;
setShareUrl(url);
try {
if (navigator.share) await navigator.share({ title: item.title, url });
else if (navigator.clipboard) { await navigator.clipboard.writeText(url); notify('视频链接已复制'); }
else setPanel('share');
} catch (error) { if ((error as Error).name !== 'AbortError') setPanel('share'); }
}
const results = videos.filter(video => `${video.title}${video.author}${video.tags.join('')}`.includes(query.trim()));
return <>
<Head><title>瞬间 FLOW · 发现生活的另一面</title><meta name="description" content="沉浸式短视频体验,记录每一个值得停留的瞬间。" /><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /><meta name="theme-color" content="#08080b" /></Head>
<div className="app-shell" inert={!!panel}>
<main className="main">
<SwipeFeed ref={feed} onChange={change} disabled={!!panel}>
{videos.map((video, videoIndex) => {
return <VideoCard key={video.id} item={video} position={pagePosition(videoIndex, index)} active={videoIndex === index}
playing={playing} muted={muted} reaction={reactions[video.id] || {}} commentCount={comments[video.id]?.length || 0}
setPlaying={setPlaying} toggleMuted={() => setMuted(value => !value)} onReact={(field, force) => react(video.id, field, force)}
onComment={() => setPanel('comments')} onShare={share} onTag={tag => { setQuery(tag); setPanel('search'); }} />;
})}
</SwipeFeed>
<header className="feed-header">
<a className="brand" href="/" aria-label="瞬间首页"><Music2 /><strong>瞬间</strong><span>FLOW</span></a>
<button className="mobile-sound" aria-label={muted ? '开启声音' : '静音'} onClick={() => setMuted(value => !value)}>{muted ? <VolumeX /> : <Volume2 />}</button>
<nav className="feed-tabs"><button onClick={() => notify('点击作者头像即可关注')}>关注</button><button className="selected">推荐</button></nav>
<button className="search-trigger" aria-label="搜索视频" onClick={() => setPanel('search')}><Search /><span>搜索你感兴趣的内容</span><kbd>/</kbd></button>
</header>
<div className="next-controls"><button aria-label="上一个视频" onClick={() => feed.current?.next(-1)}><ChevronUp /></button><span /><button aria-label="下一个视频" onClick={() => feed.current?.next(1)}><ChevronDown /></button></div>
<div className="feed-position" aria-hidden="true">{videos.map((video, i) => <i key={video.id} className={i === index ? 'active' : ''} />)}</div>
</main>
<nav className="mobile-bottom"><button className="selected" onClick={() => notify('正在浏览推荐视频')}><Home fill="currentColor" />首页</button><button onClick={() => setPanel('search')}><Compass />发现</button><button className="create" aria-label="发布提示" onClick={() => notify('当前为浏览演示,暂未开放发布')}><Plus /></button><button onClick={() => setPanel('comments')}><MessageCircle />消息</button><button onClick={() => notify('无需登录,点赞与收藏保存在此设备')}><UserRound />我</button></nav>
</div>
{panel && <div className={`modal-backdrop ${closing ? 'closing' : ''}`} onClick={closePanel}>
<section ref={drawer} className="panel" role="dialog" aria-modal="true" aria-labelledby="panel-title" onClick={event => event.stopPropagation()}>
<div className="sheet-grip" onPointerDown={event => { sheetGesture.current = { startY: event.clientY, dy: 0 }; event.currentTarget.setPointerCapture(event.pointerId); }}
onPointerMove={event => { if (!sheetGesture.current || !drawer.current) return; const dy = Math.max(0, event.clientY - sheetGesture.current.startY); sheetGesture.current.dy = dy; drawer.current.style.transform = `translateY(${dy}px)`; }}
onPointerUp={() => { if (!sheetGesture.current || !drawer.current) return; if (sheetGesture.current.dy > 90) closePanel(); drawer.current.style.transform = ''; sheetGesture.current = null; }}><span /></div>
<header><h2 id="panel-title">{panel === 'comments' ? `评论 · ${item.comments + (comments[item.id]?.length || 0)}` : panel === 'search' ? '发现好瞬间' : '分享这一刻'}</h2><button aria-label="关闭" onClick={closePanel}><X /></button></header>
{panel === 'comments' ? <>
<p className="panel-note">演示评论 · 新评论仅在本次浏览中保留</p>
<div className="comments">{[...initialComments, ...comments[item.id] || []].map((comment, i) => <article key={`${i}-${comment.text}`}><span className="comment-avatar">{comment.name[0]}</span><div><strong>{comment.name}</strong><p>{comment.text}</p><small>刚刚 · 回复</small></div><Heart size={15} /></article>)}</div>
<form onSubmit={event => { event.preventDefault(); if (!draft.trim()) return; setComments(previous => ({ ...previous, [item.id]: [...previous[item.id] || [], { name: '访客', text: draft.trim() }] })); setDraft(''); }}><input aria-label="评论内容" placeholder="留下你的心情…" value={draft} maxLength={200} onChange={event => setDraft(event.target.value)} /><button aria-label="发送评论" disabled={!draft.trim()}><Send size={20} /></button></form>
</> : panel === 'search' ? <>
<div className="search-input"><Search size={20} /><input aria-label="搜索内容" placeholder="搜索标题、作者或标签" value={query} onChange={event => setQuery(event.target.value)} /></div>
<div className="search-results">{results.map(video => <button key={video.id} onClick={() => { setIndex(videos.findIndex(v => v.id === video.id)); setPlaying(true); closePanel(); }}><img src={video.poster} alt="" /><span><strong>{video.title}</strong><small>@{video.author} · {video.tags[0]}</small></span><Play size={18} /></button>)}{!results.length && <p>还没有找到,试试“日落”或“海”。</p>}</div>
</> : <><p className="panel-note">复制下面的链接,即可分享当前视频。</p><input className="share-link" aria-label="视频链接" readOnly value={shareUrl} onFocus={event => event.target.select()} /></>}
</section>
</div>}
<div className={`toast ${toast ? 'visible' : ''}`} role="status">{toast}</div>
</>;
}
+25 -7
View File
File diff suppressed because one or more lines are too long
+50
View File
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import ts from 'typescript';
const source = readFileSync(new URL('../lib/gestures.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } });
const exports = {};
vm.runInNewContext(compiled.outputText, { exports });
const { swipeDirection, pagePosition } = exports;
test('short slow drags spring back in both directions', () => {
assert.equal(swipeDirection(-100, -.1, 800), 0);
assert.equal(swipeDirection(100, .1, 800), 0);
});
test('long drags land on the adjacent page', () => {
assert.equal(swipeDirection(-180, 0, 800), 1);
assert.equal(swipeDirection(180, 0, 800), -1);
});
test('quick flicks commit without requiring a long drag', () => {
assert.equal(swipeDirection(-45, -.6, 800), 1);
assert.equal(swipeDirection(45, .6, 800), -1);
});
test('tiny pointer jitter never changes videos', () => {
assert.equal(swipeDirection(-6, -2, 800), 0);
assert.equal(swipeDirection(0, 2, 800), 0);
});
test('threshold adapts to viewport height', () => {
assert.equal(swipeDirection(-130, 0, 600), 1);
assert.equal(swipeDirection(-130, 0, 1000), 0);
assert.equal(swipeDirection(-130, 0, 0), 0);
});
test('every active page has exactly one previous and one next page', () => {
for (let active = 0; active < 3; active++) {
assert.equal(pagePosition(active, active), 0);
assert.equal(pagePosition((active + 1) % 3, active), 1);
assert.equal(pagePosition((active + 2) % 3, active), -1);
}
});
test('recycling at the loop seam preserves the on-screen landing position', () => {
for (let active = 0; active < 3; active++) {
for (const direction of [-1, 1]) {
const next = (active + direction + 3) % 3;
// A dragged neighbor reaches y=0 before recycling; it stays at y=0 after.
assert.equal(pagePosition(next, active) - direction, 0);
assert.equal(pagePosition(next, next), 0);
}
}
});