From c0b752da3a51d238f6bd07df0136edf84c7738a4 Mon Sep 17 00:00:00 2001 From: hexin-codex-bot Date: Thu, 8 Oct 2026 16:09:57 +0800 Subject: [PATCH] feat: add true gesture-driven full-viewport video feed and polished motion --- Dockerfile | 2 +- README.md | 7 +- components/SwipeFeed.tsx | 119 +++++++++++++++++++++++ components/VideoCard.tsx | 81 ++++++++++++++++ lib/gestures.ts | 12 +++ package.json | 1 + pages/index.tsx | 198 ++++++++++++++++++++++++++++++--------- styles/globals.css | 32 +++++-- tests/gestures.test.mjs | 50 ++++++++++ 9 files changed, 449 insertions(+), 53 deletions(-) create mode 100644 components/SwipeFeed.tsx create mode 100644 components/VideoCard.tsx create mode 100644 lib/gestures.ts create mode 100644 tests/gestures.test.mjs diff --git a/Dockerfile b/Dockerfile index 2dbd1c5..6bcff17 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 23a539a..3f1cf33 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,14 @@ pnpm install --frozen-lockfile pnpm dev ``` -浏览器打开 http://localhost:3000 。手机与电脑自适应:手机全屏触摸上下切换,电脑宽屏播放,支持滚轮、上下方向键及右侧切换按钮。空格暂停,M 切换声音。 +浏览器打开 http://localhost:3000 。页面直接铺满浏览器宽度,没有侧栏或固定宽度的播放器外框。手机保留底部导航,电脑使用浮层控制。 + +上下拖动时,上下两条视频会随鼠标或手指一起移动;轻拖后松开回弹,超过约 19% 屏幕高度或快速甩动则翻页。三条真实页面循环复用,避免切换时闪屏。电脑也支持滚轮、上下方向键及右侧切换按钮。空格暂停,M 切换声音,斜杠搜索。 ## 交互 -- 三段本地 MP4 无限循环切换,单击暂停、双击点赞、拖动进度、静音/开启声音、桌面全屏。 +- 三段本地 MP4 无限循环切换,单击暂停、双击位置爱心与粒子、拖动进度、静音/开启声音、桌面全屏。 +- 点赞收藏弹性反馈,评论面板滑入滑出,手机评论面板可拖动顶部把手向下关闭;系统减少动态效果设置受到尊重。 - 点赞、收藏、关注按视频保存在本机 localStorage,无需账号。 - 搜索默认数据、标签搜索、分享视频专属链接、评论抽屉及本次会话评论。 - 其他导航提供演示提示,不包含其他业务页面。 diff --git a/components/SwipeFeed.tsx b/components/SwipeFeed.tsx new file mode 100644 index 0000000..227f0c6 --- /dev/null +++ b/components/SwipeFeed.tsx @@ -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(function SwipeFeed({ children, onChange, disabled }, ref) { + const viewport = useRef(null); + const track = useRef(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 | null>(null); + const wheelCooldown = useRef(0); + const finishTimer = useRef | 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) { + 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) { + 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) { + 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) { + 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
{ 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(); } }}> +
{Children.toArray(children)}
+
; +}); diff --git a/components/VideoCard.tsx b/components/VideoCard.tsx new file mode 100644 index 0000000..b229c06 --- /dev/null +++ b/components/VideoCard.tsx @@ -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(null); + const card = useRef(null); + const clickTimer = useRef | 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
+
; +} diff --git a/lib/gestures.ts b/lib/gestures.ts new file mode 100644 index 0000000..5156f43 --- /dev/null +++ b/lib/gestures.ts @@ -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; +} diff --git a/package.json b/package.json index a202680..77ed6ca 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pages/index.tsx b/pages/index.tsx index 793b158..395e817 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -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>({}),[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>({}),[tab,setTab]=useState('推荐'),[error,setError]=useState(false); - const video=useRef(null),stage=useRef(null),touch=useRef({x:0,y:0}),lastScroll=useRef(0),clickTimer=useRef|null>(null),toastTimer=useRef|null>(null),burstTimer=useRef|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('[role="dialog"]');dialog?.querySelector('button,input')?.focus();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')setPanel(null);if(e.key==='Tab'){const list=dialog?.querySelectorAll('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 <>瞬间 FLOW · 发现生活的另一面 -
- -
FOR YOU

发现生活的另一面精选推荐

偶遇一个好瞬间
-
{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);}}}> -
↑↓ 切换视频 Space 暂停 / 播放0{index+1} / 0{videos.length} 好风景,循环看
-
-
-
- {panel&&
setPanel(null)}>
e.stopPropagation()}>

{panel==='comments'?`评论 · ${item.comments+(comments[item.id]?.length||0)}`:'发现好瞬间'}

{panel==='comments'?<>

演示评论 · 新评论仅在本次浏览中保留

{[...initialComments,...comments[item.id]||[]].map((c,i)=>
{c.name[0]}
{c.name}

{c.text}

刚刚 · 回复
)}
{e.preventDefault();if(!draft.trim())return;setComments(c=>({...c,[item.id]:[...c[item.id]||[],{name:'访客',text:draft.trim()}]}));setDraft('');}}>setDraft(e.target.value)}/>
:<>
setQuery(e.target.value)}/>
{results.map(v=>)}{results.length===0&&

还没有找到,试试“日落”或“海”。

}
}
} -
{toast}
- ; +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>({}); + 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>({}); + const [shareUrl, setShareUrl] = useState(''); + const feed = useRef(null); + const toastTimer = useRef | null>(null); + const closeTimer = useRef | null>(null); + const drawer = useRef(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('button,input')?.focus(); + const key = (event: KeyboardEvent) => { + if (event.key === 'Escape') closePanel(); + if (event.key !== 'Tab') return; + const elements = element?.querySelectorAll('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 <> + 瞬间 FLOW · 发现生活的另一面 +
+
+ + {videos.map((video, videoIndex) => { + return setMuted(value => !value)} onReact={(field, force) => react(video.id, field, force)} + onComment={() => setPanel('comments')} onShare={share} onTag={tag => { setQuery(tag); setPanel('search'); }} />; + })} + +
+ 瞬间FLOW + + + +
+
+ +
+ +
+ {panel &&
+
event.stopPropagation()}> +
{ 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; }}>
+

{panel === 'comments' ? `评论 · ${item.comments + (comments[item.id]?.length || 0)}` : panel === 'search' ? '发现好瞬间' : '分享这一刻'}

+ {panel === 'comments' ? <> +

演示评论 · 新评论仅在本次浏览中保留

+
{[...initialComments, ...comments[item.id] || []].map((comment, i) =>
{comment.name[0]}
{comment.name}

{comment.text}

刚刚 · 回复
)}
+
{ event.preventDefault(); if (!draft.trim()) return; setComments(previous => ({ ...previous, [item.id]: [...previous[item.id] || [], { name: '访客', text: draft.trim() }] })); setDraft(''); }}> setDraft(event.target.value)} />
+ : panel === 'search' ? <> +
setQuery(event.target.value)} />
+
{results.map(video => )}{!results.length &&

还没有找到,试试“日落”或“海”。

}
+ : <>

复制下面的链接,即可分享当前视频。

event.target.select()} />} +
+
} +
{toast}
+ ; } diff --git a/styles/globals.css b/styles/globals.css index 8237b2a..c379840 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1,7 +1,25 @@ -*{box-sizing:border-box}html,body,#__next{margin:0;width:100%;height:100%;background:#0b0b0e;color:#f5f5f7}body{font-family:Arial,'PingFang SC','Microsoft YaHei',sans-serif;-webkit-font-smoothing:antialiased}button,a,input{-webkit-tap-highlight-color:transparent}button{font:inherit;color:inherit;border:0;background:none;cursor:pointer;padding:0}button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid #65e9e0;outline-offset:5px}button:disabled{opacity:.35;cursor:default}a{color:inherit;text-decoration:none}button svg{flex-shrink:0}button:hover{filter:brightness(1.18)}.app-shell{display:flex;height:100dvh;overflow:hidden}.sidebar{width:236px;flex-shrink:0;padding:33px 23px 23px;border-right:1px solid #ffffff09;display:flex;flex-direction:column}.brand{display:flex;align-items:center;font-size:27px;font-weight:850;letter-spacing:2px;gap:10px}.brand-mark{color:#fff;filter:drop-shadow(-2px -1px 0 #25eee1) drop-shadow(2px 2px 0 #fe345b);display:flex}.brand-en{font-size:10px;letter-spacing:2px;color:#6e6e7d;align-self:flex-end;margin-bottom:5px;margin-left:-5px}.search-box{background:#19191f;color:#8a8a95;border:1px solid #ffffff05;display:flex;align-items:center;gap:9px;padding:12px 10px;border-radius:9px;margin:35px 0 23px;font-size:12px}.search-box kbd{margin-left:auto;border:1px solid #393940;border-radius:3px;padding:0 4px;font-size:12px}.nav-item{display:flex;align-items:center;width:100%;gap:15px;padding:15px 17px;border-radius:9px;font-size:16px;font-weight:600;margin-bottom:7px;color:#a5a5af}.nav-item.active{background:#fe2c5518;color:#ff3b61}.active-dot{width:6px;height:6px;border-radius:50%;background:#ff3b61;margin-left:auto}.sidebar-line{height:1px;background:#ffffff0c;margin:21px 3px}.sidebar-label{font-size:11px;letter-spacing:2px;color:#666673;margin:1px 15px 15px}.channel{display:flex;align-items:center;gap:12px;padding:13px 15px;color:#a5a5b0;font-size:13px}.channel svg{margin-left:auto;color:#545460}.channel-dot{width:7px;height:7px;border-radius:50%}.dot-0{background:#dcaa86}.dot-1{background:#72b3a3}.dot-2{background:#9b88c4}.sidebar-bottom{margin-top:auto;padding:20px 8px 0}.mini-brand{font-size:12px;color:#aaaab4;line-height:2}.mini-brand span{display:block;font-size:8px;letter-spacing:1px;color:#54545f}.sidebar-bottom p{color:#4c4c59;font-size:10px;margin-top:17px}.demo-pill{display:inline-flex;align-items:center;gap:6px;color:#767682;font-size:10px;background:#17171e;border:1px solid #ffffff06;border-radius:20px;padding:7px 9px;margin-top:10px}.demo-pill>span,.header-note>span{height:5px;width:5px;border-radius:50%;background:#66c3a1}.main{flex:1;min-width:0;padding:26px 82px 0 32px;position:relative;display:flex;flex-direction:column}.desktop-header{display:flex;align-items:center;justify-content:space-between;flex-shrink:0;padding:0 0 23px}.eyebrow{color:#656574;font-size:9px;letter-spacing:3px;font-weight:700}h1{font-size:20px;letter-spacing:1px;margin:8px 0 0;font-weight:650}h1 span{font-size:10px;letter-spacing:0;color:#ada6a2;border:1px solid #6c5d503f;background:#302a232f;padding:4px 7px;border-radius:4px;vertical-align:middle;margin-left:14px;font-weight:400}.header-note{display:flex;align-items:center;gap:7px;color:#858591;font-size:11px}.stage{position:relative;flex:1;min-height:0;overflow:hidden;border-radius:13px;background:#182333;isolation:isolate;touch-action:none}.video{position:absolute;width:100%;height:100%;object-fit:cover;object-position:center 48%}.scrim{position:absolute;inset:0;background:linear-gradient(0deg,#05070be6 0%,#07090e80 16%,transparent 55%,#0d152533 100%);pointer-events:none}.video-hit{position:absolute;inset:0;width:100%;height:100%}.stage-top{position:absolute;top:24px;left:25px;right:24px;display:flex;gap:16px;align-items:center;pointer-events:none}.curated{font-size:11px;letter-spacing:2px;border:1px solid #ffffff38;border-radius:4px;padding:6px 9px;background:#ffffff0b;display:flex;align-items:center;gap:7px}.curated>span{width:4px;height:4px;background:#fff;border-radius:50%}.location{font-size:11px;display:flex;gap:4px;align-items:center;color:#fffc}.sound-top{margin-left:auto;background:#0003;display:flex;padding:9px;border-radius:50%;pointer-events:auto}.scene-copy{position:absolute;top:25%;left:0;width:100%;text-align:center;pointer-events:none;text-shadow:0 3px 20px #152c3d44}.scene-copy>span{display:block;letter-spacing:6px;font-size:10px;opacity:.8}.scene-copy strong{font-family:'Songti SC','Noto Serif SC',SimSun,serif;display:block;letter-spacing:15px;font-weight:400;font-size:clamp(35px,4.7vw,75px);margin:21px 0 18px}.scene-copy i{font-style:normal;font-size:8px;letter-spacing:3px;opacity:.5}.video-info{position:absolute;bottom:83px;left:28px;right:110px;pointer-events:none}.video-info button{pointer-events:auto}.editor-label{font-size:10px;color:#fff9;letter-spacing:1px}.editor-label span{margin:0 8px}.video-info h2{font-size:20px;margin:11px 0 12px;display:flex;align-items:center;gap:8px;letter-spacing:.2px}.verified{display:inline-flex;background:#54b8d8;border-radius:50%;padding:2px}.video-info h3{font-size:16px;font-weight:500;margin:0 0 7px}.video-info p{font-size:13px;color:#ffffffc0;margin:0 0 10px;line-height:1.6}.tags{display:flex;gap:12px;flex-wrap:wrap}.tags button{font-size:12px;color:#ece0c8}.music{display:flex;align-items:center;gap:7px;margin-top:16px;font-size:11px;color:#fff9}.music-bars{margin-left:12px;color:#fffa;font-size:10px}.actions{position:absolute;right:26px;bottom:88px;display:flex;align-items:center;gap:22px;flex-direction:column}.avatar{border-radius:50%;height:44px;width:44px;position:relative;border:2px solid white;font-family:serif;font-size:21px;color:#fff;text-shadow:0 1px 4px #0004}.avatar>span{display:flex;align-items:center;justify-content:center;background:#ff3058;border-radius:50%;position:absolute;width:19px;height:19px;bottom:-9px;left:11px}.action{display:flex;flex-direction:column;align-items:center;gap:7px;filter:drop-shadow(0 2px 4px #0003)}.action>svg{height:30px;width:30px;stroke-width:1.4;transition:transform .2s}.action:active>svg{transform:scale(1.22)}.action span{font-size:11px;font-weight:600}.action.liked{color:#ff365e}.action.saved{color:#ffd266}.record{width:39px;height:39px;background:repeating-radial-gradient(#26262c 0 2px,#121216 3px 5px);border:1px solid #ffffff1a;border-radius:50%;display:grid;place-items:center}.record>span{width:23px;height:23px;border-radius:50%;display:grid;place-items:center}.spinning{animation:spin 8s linear infinite}.player-controls{position:absolute;bottom:0;width:100%;padding:0 24px;background:linear-gradient(transparent,#080b1166)}.progress{--progress:0%;display:block;appearance:none;width:100%;height:3px;border:0;padding:0;cursor:pointer;background:linear-gradient(to right,#fffc var(--progress),#ffffff35 var(--progress));border-radius:9px;margin:0;touch-action:none}.progress::-webkit-slider-thumb{appearance:none;width:8px;height:8px;border-radius:50%;background:white}.progress::-moz-range-thumb{width:8px;height:8px;background:white;border:0;border-radius:50%}.control-row{display:flex;height:51px;align-items:center;gap:18px}.control-row button{display:flex;align-items:center}.time{font-size:10px;font-variant-numeric:tabular-nums;letter-spacing:.5px}.time>span{color:#fff7}.control-hint{font-size:10px;color:#fff5;margin:auto}.quality{font-size:11px;gap:5px}.quality span{font-size:8px;padding:1px 3px;border:1px solid #fff5;border-radius:2px}.desktop-footer{height:53px;display:flex;align-items:center;justify-content:space-between;color:#575764;font-size:10px;flex-shrink:0}.desktop-footer>span{display:flex;align-items:center;gap:6px}.key{border:1px solid #303039;padding:2px 5px;border-radius:4px;color:#858591}.key.space{font-size:9px}.footer-divider{height:10px;width:1px;background:#303039;margin:0 9px}.current-number{color:#d9d9df}.next-controls{position:absolute;right:21px;top:47%;display:flex;align-items:center;flex-direction:column;border:1px solid #ffffff09;background:#16161c;border-radius:24px}.next-controls button{padding:14px 10px;display:flex;color:#9696a2}.next-controls>span{height:1px;width:18px;background:#ffffff0b}.next-controls svg{width:20px;height:20px}.paused,.heart-burst{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);pointer-events:none;color:#ffffffaa;filter:drop-shadow(0 4px 18px #0003)}.heart-burst{color:#ff3058;animation:heart .8s ease-out both}.video-error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);padding:24px;background:#16161ceb;border-radius:12px;font-size:14px}.video-error button{display:block;margin:16px auto 0;color:#61dfd6}.mobile-top,.mobile-bottom{display:none}.modal-backdrop{position:fixed;inset:0;z-index:20;background:#0008;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(8px)}.panel{width:min(450px,94vw);max-height:80dvh;background:#19191f;border:1px solid #ffffff12;border-radius:18px;padding:24px;display:flex;flex-direction:column;box-shadow:0 24px 100px #0008}.panel>header{display:flex;align-items:center;justify-content:space-between}.panel h2{font-size:18px;margin:0}.panel header button{display:flex;color:#aaa}.panel-note{font-size:11px;color:#828290;margin-top:17px}.comments{overflow:auto;min-height:120px;max-height:42dvh}.comments article{display:flex;gap:12px;margin:24px 0;align-items:flex-start}.comment-avatar{display:grid;place-items:center;border-radius:50%;width:34px;height:34px;background:#343441;color:#c6bdd7;flex-shrink:0}.comments article>div{flex:1}.comments strong{font-size:12px;color:#92929f}.comments p{font-size:13px;line-height:1.6;margin:6px 0}.comments small{font-size:10px;color:#62626f}.comments article>svg{color:#747480;margin-top:18px}.panel form,.search-input{display:flex;align-items:center;gap:12px;background:#272730;border-radius:9px;padding:11px 13px}.panel form{margin-top:20px}.panel input{border:0;outline:none;background:none;color:#fff;font:inherit;font-size:13px;flex:1;min-width:0}.panel form button{color:#ff476d}.search-input{margin-top:25px;color:#9494a2}.search-results{overflow:auto}.search-results>button{display:flex;align-items:center;width:100%;text-align:left;gap:14px;margin:20px 0}.search-results img{width:60px;height:75px;object-fit:cover;border-radius:7px}.search-results span{flex:1}.search-results strong{font-size:13px;display:block}.search-results small{font-size:11px;color:#878795;display:block;margin-top:10px}.search-results>p{font-size:13px;color:#999;padding:20px}.toast{position:fixed;z-index:50;bottom:100px;left:50%;transform:translate(-50%,15px);background:#24242ef2;border:1px solid #ffffff17;box-shadow:0 8px 30px #0005;border-radius:30px;padding:12px 22px;max-width:85vw;font-size:13px;opacity:0;pointer-events:none;transition:.2s;text-align:center}.toast.visible{opacity:1;transform:translate(-50%,0)}.stage:fullscreen{border-radius:0}.stage:fullscreen .video{object-fit:contain;background:#000}@keyframes spin{to{transform:rotate(360deg)}}@keyframes heart{0%{opacity:0;transform:translate(-50%,-50%) scale(.3) rotate(-20deg)}35%{opacity:1;transform:translate(-50%,-50%) scale(1.1) rotate(-12deg)}100%{opacity:0;transform:translate(-50%,-90%) scale(1.3) rotate(-12deg)}} -@media(min-width:1600px){.sidebar{width:260px}.main{padding-left:42px;padding-right:94px}.video-info{bottom:95px;left:35px}.actions{gap:27px;right:32px}.video-info h2{font-size:23px}.scene-copy{top:26%}} -@media(max-width:1050px) and (min-width:701px){.sidebar{width:190px;padding:28px 14px}.brand{font-size:24px}.brand-en{font-size:8px}.main{padding-left:20px;padding-right:65px}.header-note{display:none}.scene-copy strong{font-size:45px;letter-spacing:8px}.scene-copy>span{letter-spacing:3px}.actions{right:20px;gap:20px}.video-info{left:23px}.video-info p{max-width:80%}.control-hint{visibility:hidden}.next-controls{right:12px}} -@media(max-width:700px){.sidebar,.desktop-header,.desktop-footer,.next-controls,.stage-top,.control-row,.editor-label{display:none}.main{padding:0}.stage{border-radius:0}.app-shell{flex-direction:column}.mobile-bottom{display:flex;flex-shrink:0;height:calc(58px + env(safe-area-inset-bottom));padding:6px 8px calc(6px + env(safe-area-inset-bottom));background:#08080b;align-items:center;justify-content:space-around;border-top:1px solid #ffffff12}.mobile-bottom button{display:flex;flex-direction:column;gap:3px;align-items:center;font-size:10px;color:#aaaab1;width:54px}.mobile-bottom button svg{width:22px;height:22px}.mobile-bottom .selected{color:white;font-weight:600}.mobile-bottom .create{width:42px;height:29px;border-radius:7px;background:#fff;color:#08080b;box-shadow:-5px 0 #25e2db,5px 0 #fc315a;display:grid;place-items:center;margin:0 6px}.mobile-bottom .create svg{width:26px;height:26px}.mobile-top{position:absolute;top:calc(18px + env(safe-area-inset-top));left:18px;right:18px;display:flex;justify-content:space-between;align-items:center}.mobile-top>div{display:flex;gap:28px}.mobile-top button{font-size:17px;font-weight:600;color:#fffb;position:relative}.mobile-top button.selected{color:#fff}.mobile-top .selected:after{content:'';position:absolute;width:22px;height:3px;background:white;bottom:-10px;left:50%;transform:translateX(-50%);border-radius:2px}.mobile-top>button{display:flex}.video{object-position:50% 50%}.scene-copy{top:29%}.scene-copy>span{font-size:8px;letter-spacing:3px}.scene-copy strong{font-size:43px;letter-spacing:8px;margin:22px 0}.scene-copy i{font-size:6px;letter-spacing:2px}.video-info{left:15px;bottom:24px;right:69px}.video-info h2{font-size:17px;margin-bottom:11px}.video-info h3{font-size:14px;line-height:1.5;margin-bottom:4px}.video-info p{font-size:12px;line-height:1.6;margin-bottom:7px}.tags{gap:6px 9px}.tags button{font-size:12px}.music{margin-top:12px;font-size:11px;gap:6px}.music-bars{display:none}.actions{right:12px;bottom:32px;gap:23px}.action>svg{width:32px;height:32px}.action span{font-size:11px}.avatar{height:43px;width:43px;margin-bottom:3px}.record{width:36px;height:36px}.player-controls{padding:0;bottom:1px}.progress{height:2px}.progress::-webkit-slider-thumb{height:5px;width:5px}.scrim{background:linear-gradient(0deg,#06090bcc,transparent 60%,#0c172044)}.modal-backdrop{align-items:flex-end;backdrop-filter:blur(4px)}.panel{width:100%;max-height:78dvh;border-radius:18px 18px 0 0;padding:22px 20px calc(22px + env(safe-area-inset-bottom))}.toast{bottom:85px;font-size:12px}.paused svg{width:46px}.video-error{width:75%;text-align:center}} -@media(max-height:680px){.actions{gap:13px}.action>svg{width:27px;height:27px}.scene-copy{top:23%}.sidebar-bottom{padding-top:0}.sidebar-bottom p{margin-top:10px}.video-info p{display:none}.video-info{bottom:72px}.record{display:none}} -@media(max-height:680px) and (max-width:700px){.video-info{bottom:22px}.actions{bottom:30px}.scene-copy strong{font-size:35px}.scene-copy i{display:none}} -@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}} +:root{color-scheme:dark;--pink:#fe2c55;--ease:cubic-bezier(.22,.72,.18,1)} +*{box-sizing:border-box}html,body,#__next{margin:0;width:100%;height:100%;overflow:hidden;background:#08080b;color:#fff;overscroll-behavior:none} +body{font-family:Arial,'PingFang SC','Microsoft YaHei',sans-serif;-webkit-font-smoothing:antialiased} +button,a,input{-webkit-tap-highlight-color:transparent}button{font:inherit;color:inherit;border:0;background:none;cursor:pointer;padding:0}button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid #65e9e0;outline-offset:5px}button:disabled{opacity:.35;cursor:default}a{color:inherit;text-decoration:none}button svg{flex-shrink:0}button{transition:transform .2s var(--ease),color .2s,background .2s}button:active:not(.video-hit){transform:scale(.92)} +.app-shell{width:100%;height:100dvh;display:flex;flex-direction:column}.main{flex:1;min-height:0;width:100%;position:relative;overflow:hidden} +.feed-viewport,.feed-track{position:absolute;inset:0;width:100%;height:100%}.feed-viewport{overflow:hidden;touch-action:none;user-select:none;-webkit-user-select:none;cursor:grab}.feed-viewport[data-moving]{cursor:grabbing}.feed-track{will-change:transform}.video-page{position:absolute;inset:0;width:100%;height:100%;overflow:hidden;isolation:isolate;background:#182333;contain:layout paint}.video{position:absolute;width:100%;height:100%;object-fit:cover;object-position:center 48%;pointer-events:none}.scrim{position:absolute;inset:0;background:linear-gradient(0deg,#040609c9,transparent 45%,#070c1748);pointer-events:none}.video-hit{position:absolute;inset:0;width:100%;height:100%;cursor:inherit;touch-action:none} +.feed-header{position:absolute;z-index:2;top:0;left:0;right:0;height:92px;padding:26px 35px;display:flex;align-items:center;pointer-events:none;background:linear-gradient(#070a1428,transparent)}.feed-header button,.feed-header a{pointer-events:auto}.brand{display:flex;align-items:center;gap:10px}.brand>svg{width:28px;height:28px;filter:drop-shadow(-2px -1px 0 #25eee1) drop-shadow(2px 2px 0 #fe345b)}.brand strong{font-size:25px;letter-spacing:2px}.brand>span{font-size:9px;letter-spacing:2px;opacity:.65;align-self:flex-end;margin-bottom:4px}.feed-tabs{position:absolute;left:50%;transform:translateX(-50%);display:flex;gap:32px;font-size:18px;font-weight:600;text-shadow:0 2px 8px #0003}.feed-tabs button{color:#ffffffa0;position:relative}.feed-tabs .selected{color:white}.feed-tabs .selected:after{content:'';position:absolute;width:24px;height:3px;background:#fff;border-radius:3px;bottom:-12px;left:50%;transform:translateX(-50%)}.search-trigger{margin-left:auto;display:flex;align-items:center;gap:10px;border:1px solid #ffffff2b;border-radius:22px;padding:10px 14px;background:#00000012;color:#ffffffe0}.search-trigger svg{width:18px;height:18px}.search-trigger span{font-size:12px}.search-trigger kbd{font-size:11px;opacity:.5;margin-left:18px}.mobile-sound{display:none} +.scene-copy{position:absolute;top:27%;width:100%;text-align:center;pointer-events:none;text-shadow:0 3px 20px #152c3d44}.scene-copy>span{display:block;letter-spacing:6px;font-size:10px;opacity:.8}.scene-copy strong{font-family:'Songti SC','Noto Serif SC',SimSun,serif;display:block;letter-spacing:15px;font-weight:400;font-size:clamp(40px,5vw,82px);margin:23px 0 19px}.scene-copy i{font-style:normal;font-size:8px;letter-spacing:3px;opacity:.6} +.video-info{position:absolute;bottom:91px;left:35px;right:130px;pointer-events:none;text-shadow:0 1px 8px #0003}.video-info button{pointer-events:auto}.location{font-size:11px;display:flex;gap:5px;align-items:center;color:#fffb}.location>span{margin:0 4px}.video-info h2{font-size:22px;margin:11px 0 12px;display:flex;align-items:center;gap:8px;letter-spacing:.2px}.verified{display:inline-flex;background:#54b8d8;border-radius:50%;padding:2px}.video-info h3{font-size:16px;font-weight:500;margin:0 0 7px}.video-info p{font-size:13px;color:#ffffffcc;margin:0 0 10px;line-height:1.6}.tags{display:flex;gap:12px;flex-wrap:wrap}.tags button{font-size:13px;color:#f5e7ca}.music{display:flex;align-items:center;gap:7px;margin-top:15px;font-size:11px;color:#fff9}.music-bars{display:flex;gap:3px;align-items:center;margin-left:10px;height:13px}.music-bars i{display:block;width:2px;height:5px;background:#fffb}.music-bars i:nth-child(2){height:11px;animation-delay:-.3s!important}.music-bars i:nth-child(3){height:8px;animation-delay:-.5s!important}.music-bars i:nth-child(4){height:12px;animation-delay:-.2s!important}.music-bars.playing i{animation:equalizer .8s ease-in-out infinite alternate} +.actions{position:absolute;right:39px;bottom:94px;display:flex;align-items:center;gap:25px;flex-direction:column}.avatar{border-radius:50%;height:48px;width:48px;position:relative;border:2px solid white;font-family:serif;font-size:23px;color:#fff;text-shadow:0 1px 4px #0004}.avatar>span{display:flex;align-items:center;justify-content:center;background:var(--pink);border-radius:50%;position:absolute;width:20px;height:20px;bottom:-9px;left:12px;animation:badge-in .35s var(--ease)}.avatar.following>span{background:#fff;color:var(--pink)}.action{display:flex;flex-direction:column;align-items:center;gap:7px;filter:drop-shadow(0 2px 4px #0003)}.action>svg{height:33px;width:33px;stroke-width:1.4;transition:color .18s,transform .2s var(--ease)}.action:active>svg{transform:scale(.8)}.action span{font-size:12px;font-weight:600;animation:count-in .25s var(--ease)}.action.liked{color:var(--pink)}.action.saved{color:#ffd266}.action.liked svg,.action.saved svg{animation:reaction-pop .5s var(--ease)}.record{width:44px;height:44px;background:repeating-radial-gradient(#26262c 0 2px,#121216 3px 5px);border:1px solid #ffffff1a;border-radius:50%;display:grid;place-items:center;animation:spin 8s linear infinite;animation-play-state:paused}.record>span{width:27px;height:27px;border-radius:50%;display:grid;place-items:center}.record.spinning{animation-play-state:running} +.player-controls{position:absolute;bottom:0;width:100%;padding:0 28px;background:linear-gradient(transparent,#080b1166)}.progress{--progress:0%;display:block;appearance:none;width:100%;height:3px;border:0;padding:0;cursor:pointer;background:linear-gradient(to right,#fffc var(--progress),#ffffff35 var(--progress));border-radius:9px;margin:0;touch-action:none;transition:height .18s}.progress:hover{height:5px}.progress::-webkit-slider-thumb{appearance:none;width:8px;height:8px;border-radius:50%;background:white;transition:transform .15s}.progress:hover::-webkit-slider-thumb{transform:scale(1.4)}.progress::-moz-range-thumb{width:8px;height:8px;background:white;border:0;border-radius:50%}.control-row{display:flex;height:55px;align-items:center;gap:20px}.control-row button{display:flex;align-items:center}.time{font-size:11px;font-variant-numeric:tabular-nums;letter-spacing:.5px}.time>span{color:#fff8}.control-hint{font-size:11px;color:#fff6;margin:auto}.quality{font-size:12px;display:flex;align-items:center;gap:5px}.quality small{font-size:8px;padding:1px 3px;border:1px solid #fff5;border-radius:2px} +.next-controls{position:absolute;right:38px;top:20%;display:flex;align-items:center;flex-direction:column;border:1px solid #ffffff26;background:#1417202b;backdrop-filter:blur(12px);border-radius:30px}.next-controls button{padding:13px;display:flex;color:#ffffffbe}.next-controls button:hover{color:white;background:#ffffff16;border-radius:24px}.next-controls>span{height:1px;width:18px;background:#ffffff20}.next-controls svg{width:19px;height:19px}.feed-position{position:absolute;right:14px;top:50%;display:flex;flex-direction:column;gap:6px;transform:translateY(-50%)}.feed-position i{width:3px;height:8px;background:#ffffff40;border-radius:5px;transition:height .35s var(--ease),background .3s}.feed-position i.active{height:20px;background:#fff} +.paused{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);pointer-events:none;color:#ffffffbd;filter:drop-shadow(0 4px 18px #0003);animation:pause-in .25s var(--ease)}.heart-burst{position:absolute;pointer-events:none;z-index:4;color:var(--pink);filter:drop-shadow(0 5px 18px #6003);animation:heart-float .95s var(--ease) forwards}.heart-burst i{position:absolute;left:50%;top:50%;width:9px;height:9px;border-radius:50%;background:#ff95a9;animation:particle .65s ease-out forwards;--x:-75px;--y:-55px}.heart-burst i:nth-of-type(2){--x:65px;--y:-70px}.heart-burst i:nth-of-type(3){--x:-70px;--y:60px}.heart-burst i:nth-of-type(4){--x:70px;--y:45px}.video-error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);padding:24px;background:#16161ceb;border-radius:12px;font-size:14px}.video-error button{display:block;margin:16px auto 0;color:#61dfd6} +.mobile-bottom{display:none}.modal-backdrop{position:fixed;inset:0;z-index:20;background:#0006;display:flex;justify-content:flex-end;animation:backdrop-in .3s ease-out}.panel{width:min(430px,94vw);height:100%;background:#16161cf5;backdrop-filter:blur(24px);border-left:1px solid #ffffff15;padding:29px 25px;display:flex;flex-direction:column;box-shadow:-24px 0 80px #0003;animation:drawer-in .42s var(--ease);transition:transform .28s var(--ease)}.panel>header{display:flex;align-items:center;justify-content:space-between}.panel h2{font-size:18px;margin:0}.panel header button{display:flex;color:#aaa;padding:5px;border-radius:50%;background:#ffffff09}.panel-note{font-size:11px;color:#828290;margin-top:20px}.comments{overflow:auto;min-height:120px;flex:1;overscroll-behavior:contain}.comments article{display:flex;gap:12px;margin:25px 0;align-items:flex-start;animation:comment-in .28s var(--ease)}.comment-avatar{display:grid;place-items:center;border-radius:50%;width:36px;height:36px;background:#343441;color:#c6bdd7;flex-shrink:0}.comments article>div{flex:1}.comments strong{font-size:12px;color:#92929f}.comments p{font-size:13px;line-height:1.6;margin:6px 0;overflow-wrap:anywhere}.comments small{font-size:10px;color:#62626f}.comments article>svg{color:#747480;margin-top:18px}.panel form,.search-input{display:flex;align-items:center;gap:12px;background:#272730;border-radius:10px;padding:13px}.panel form{margin-top:20px}.panel input{border:0;background:none;color:#fff;font:inherit;font-size:13px;flex:1;min-width:0}.panel form button{color:#ff476d;display:flex}.search-input{margin-top:25px;color:#9494a2}.search-results{overflow:auto}.search-results>button{display:flex;align-items:center;width:100%;text-align:left;gap:14px;margin:22px 0}.search-results img{width:60px;height:78px;object-fit:cover;border-radius:7px}.search-results span{flex:1}.search-results strong{font-size:13px;display:block}.search-results small{font-size:11px;color:#878795;display:block;margin-top:10px}.search-results>p{font-size:13px;color:#999;padding:20px}.share-link{flex:0!important;background:#272730!important;padding:14px;border-radius:9px;width:100%}.sheet-grip{display:none}.closing{animation:backdrop-out .24s ease-in forwards}.closing .panel{animation:drawer-out .24s ease-in forwards}.toast{position:fixed;z-index:50;bottom:100px;left:50%;transform:translate(-50%,15px);background:#24242eea;backdrop-filter:blur(16px);border:1px solid #ffffff17;box-shadow:0 8px 30px #0005;border-radius:30px;padding:12px 22px;max-width:85vw;font-size:13px;opacity:0;pointer-events:none;transition:opacity .22s,transform .3s var(--ease);text-align:center}.toast.visible{opacity:1;transform:translate(-50%,0)} +@keyframes spin{to{transform:rotate(360deg)}}@keyframes equalizer{to{transform:scaleY(.3)}}@keyframes badge-in{from{transform:scale(.3) rotate(-50deg);opacity:.2}to{transform:scale(1) rotate(0);opacity:1}}@keyframes count-in{from{transform:translateY(3px);opacity:.5}to{transform:translateY(0);opacity:1}}@keyframes reaction-pop{0%{transform:scale(.65)}45%{transform:scale(1.26)}70%{transform:scale(.93)}100%{transform:scale(1)}}@keyframes pause-in{from{opacity:0;transform:translate(-50%,-50%) scale(1.3)}to{opacity:1;transform:translate(-50%,-50%) scale(1)}}@keyframes heart-float{0%{opacity:0;transform:translate(-50%,-50%) scale(.3) rotate(-18deg)}20%{opacity:1;transform:translate(-50%,-50%) scale(1.1) rotate(-12deg)}45%{opacity:1;transform:translate(-50%,-60%) scale(1) rotate(-12deg)}100%{opacity:0;transform:translate(-50%,-130%) scale(1.1) rotate(-8deg)}}@keyframes particle{0%{transform:translate(0,0) scale(.2);opacity:0}20%{opacity:1}100%{transform:translate(var(--x),var(--y)) scale(.4);opacity:0}}@keyframes backdrop-in{from{background:#0000}to{background:#0006}}@keyframes backdrop-out{to{background:#0000}}@keyframes drawer-in{from{transform:translateX(100%);opacity:.7}to{transform:translateX(0);opacity:1}}@keyframes drawer-out{to{transform:translateX(100%);opacity:0}}@keyframes sheet-in{from{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes sheet-out{to{transform:translateY(100%)}}@keyframes comment-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}} +@media(max-width:900px){.search-trigger span,.search-trigger kbd{display:none}.search-trigger{padding:10px}.control-hint{font-size:10px}.scene-copy strong{font-size:54px;letter-spacing:10px}} +@media(max-width:700px){ + .feed-header{height:auto;padding:calc(20px + env(safe-area-inset-top)) 18px 22px}.brand{display:none}.mobile-sound{display:flex}.mobile-sound svg{width:23px;height:23px}.feed-tabs{font-size:17px;gap:29px}.feed-tabs .selected:after{bottom:-10px}.search-trigger{padding:0;border:0;background:none}.search-trigger svg{width:24px;height:24px} + .mobile-bottom{display:flex;flex-shrink:0;height:calc(58px + env(safe-area-inset-bottom));padding:6px 8px calc(6px + env(safe-area-inset-bottom));background:#08080b;align-items:center;justify-content:space-around;border-top:1px solid #ffffff10}.mobile-bottom button{display:flex;flex-direction:column;gap:3px;align-items:center;font-size:10px;color:#aaaab1;width:54px}.mobile-bottom button svg{width:22px;height:22px}.mobile-bottom .selected{color:white;font-weight:600}.mobile-bottom .create{width:42px;height:29px;border-radius:7px;background:#fff;color:#08080b;box-shadow:-5px 0 #25e2db,5px 0 #fc315a;display:grid;place-items:center;margin:0 6px}.mobile-bottom .create svg{width:26px;height:26px} + .video{object-position:50% 50%}.scene-copy{top:29%}.scene-copy>span{font-size:8px;letter-spacing:3px}.scene-copy strong{font-size:43px;letter-spacing:8px;margin:22px 0}.scene-copy i{font-size:6px;letter-spacing:2px}.video-info{left:15px;bottom:25px;right:72px}.location{display:none}.video-info h2{font-size:17px;margin-bottom:11px}.video-info h3{font-size:14px;line-height:1.5;margin-bottom:4px}.video-info p{font-size:12px;line-height:1.6;margin-bottom:7px}.tags{gap:6px 9px}.tags button{font-size:12px}.music{margin-top:12px;font-size:11px;gap:6px}.music-bars{display:none}.actions{right:12px;bottom:32px;gap:23px}.action>svg{width:33px;height:33px}.action span{font-size:11px}.avatar{height:44px;width:44px;margin-bottom:3px;font-size:21px}.avatar>span{left:10px}.record{width:38px;height:38px}.record>span{width:23px;height:23px}.player-controls{padding:0;bottom:1px}.progress,.progress:hover{height:2px}.progress::-webkit-slider-thumb{height:5px;width:5px}.control-row,.next-controls,.feed-position{display:none}.scrim{background:linear-gradient(0deg,#06090bc9,transparent 60%,#0c172033)} + .modal-backdrop{align-items:flex-end}.panel{width:100%;height:65dvh;max-height:85dvh;border-radius:18px 18px 0 0;padding:0 20px calc(20px + env(safe-area-inset-bottom));border:0;border-top:1px solid #ffffff10;animation:sheet-in .4s var(--ease)}.sheet-grip{display:flex;height:27px;flex-shrink:0;align-items:center;justify-content:center;touch-action:none;cursor:grab}.sheet-grip span{width:35px;height:4px;border-radius:4px;background:#ffffff3b}.closing .panel{animation:sheet-out .24s ease-in forwards}.toast{bottom:85px;font-size:12px}.paused svg{width:49px}.video-error{width:75%;text-align:center} +} +@media(max-height:680px){.actions{gap:14px}.action>svg{width:29px;height:29px}.scene-copy{top:23%}.video-info p{display:none}.record{display:none}.next-controls{top:17%}.video-info{bottom:78px}} +@media(max-height:680px) and (max-width:700px){.video-info{bottom:22px}.actions{bottom:30px}.scene-copy strong{font-size:35px}.scene-copy i{display:none}.feed-header{padding-top:17px}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}} diff --git a/tests/gestures.test.mjs b/tests/gestures.test.mjs new file mode 100644 index 0000000..de95c87 --- /dev/null +++ b/tests/gestures.test.mjs @@ -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); + } + } +});