// Author: fruit.com.cn import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useLocale, useUi } from '../../app/providers/AppProviders'; import { CommentsSheet } from '../../shared/components/CommentsSheet'; import { ShortsSkeleton } from '../../shared/components/skeletons'; import { useFollowedChannels } from '../../shared/hooks/useFollowedChannels'; import { useToast } from '../../shared/hooks/useToast'; import { normalizePage } from '../../shared/lib/pagination'; import { dramaApi } from '../../entities/drama/api'; import { normalizeDrama, uniqueDramas } from '../../entities/drama/model'; import { ShortSlide } from './components/ShortSlide'; const PAGE_SIZE = 6; const PRELOAD_THRESHOLD = 2; export function ShortsFeedPage() { const { locale } = useLocale(); const { sidebarOpen } = useUi(); const [params] = useSearchParams(); const startId = params.get('start'); const [items, setItems] = useState([]); const [activeId, setActiveId] = useState(''); const [status, setStatus] = useState({ loading: true, error: '', hasMore: true }); const { channels: followedChannels, toggle: toggleFollow } = useFollowedChannels(); const [commentsVideo, setCommentsVideo] = useState(null); const toast = useToast(); const feedRef = useRef(null); const cursorRef = useRef(null); const loadingRef = useRef(false); const requestIdRef = useRef(0); const loadPage = useCallback(async (reset) => { if (loadingRef.current && !reset) return; const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; loadingRef.current = true; setStatus((prev) => ({ ...prev, loading: true, error: '' })); try { const payload = await dramaApi.feed({ cursor: reset ? null : cursorRef.current, size: PAGE_SIZE }); if (requestId !== requestIdRef.current) return; const list = uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code))); cursorRef.current = payload?.nextCursor ?? null; setItems((prev) => (reset ? list : uniqueDramas([...prev, ...list]))); setStatus({ loading: false, error: '', hasMore: Boolean(payload?.hasMore) }); } catch (error) { if (requestId !== requestIdRef.current) return; setStatus((prev) => ({ ...prev, loading: false, error: error.message || 'Request failed' })); } finally { if (requestId === requestIdRef.current) loadingRef.current = false; } }, [locale.code]); useEffect(() => { cursorRef.current = null; setItems([]); setActiveId(''); setStatus({ loading: true, error: '', hasMore: true }); loadPage(true); }, [loadPage]); useEffect(() => { if (!items.length) return; if (items.some((item) => item.id === activeId)) return; const startIndex = startId ? items.findIndex((item) => item.id === startId) : -1; const nextIndex = startIndex >= 0 ? startIndex : 0; const node = feedRef.current; if (node && nextIndex > 0) node.scrollTop = nextIndex * node.clientHeight; setActiveId(items[nextIndex].id); }, [activeId, items, startId]); useEffect(() => { const node = feedRef.current; if (!node) return undefined; const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) setActiveId(entry.target.dataset.id); }); }, { root: node, threshold: 0.7 }); node.querySelectorAll('[data-id]').forEach((slide) => observer.observe(slide)); return () => observer.disconnect(); }, [items.length]); useEffect(() => { if (!status.hasMore || status.loading || !activeId) return; const index = items.findIndex((item) => item.id === activeId); if (index >= items.length - PRELOAD_THRESHOLD) loadPage(false); }, [activeId, items, loadPage, status.hasMore, status.loading]); const activeIndex = Math.max(0, items.findIndex((item) => item.id === activeId)); const move = (delta) => { const node = feedRef.current; if (!node) return; const next = Math.min(items.length - 1, Math.max(0, activeIndex + delta)); if (next !== activeIndex) node.scrollTo({ top: next * node.clientHeight, behavior: 'smooth' }); }; const shareVideo = async (video) => { const url = `${window.location.origin}/watch/${video.id}`; if (navigator.share) { try { await navigator.share({ title: video.title, url }); return; } catch (error) { if (error?.name === 'AbortError') return; } } try { await navigator.clipboard.writeText(url); toast.show(locale.linkCopied); } catch { toast.show(url); } }; useEffect(() => { const onKeyDown = (event) => { if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); } if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeIndex, items.length]); return ( <>
{status.loading && !items.length && } {status.error &&
{status.error}
} {!status.loading && !status.error && !items.length &&
{locale.empty}
}
{items.map((video) => ( toggleFollow(video.channel)} onOpenComments={() => setCommentsVideo(video)} onShare={() => shareVideo(video)} /> ))}
{items.length > 1 && (
)}
{commentsVideo && setCommentsVideo(null)} />} {toast.message &&
{toast.message}
} ); }