- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件 - 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分 - 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈 - 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口 - 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题 - 语言按本地偏好与 IP 识别,国旗改为内联 SVG - 清理未使用的旧站点文件,补充 ESLint 配置
157 lines
6.5 KiB
React
157 lines
6.5 KiB
React
// 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 (
|
|
<>
|
|
<section className={`shorts-stage ${sidebarOpen ? '' : 'shorts-stage-wide'}`}>
|
|
{status.loading && !items.length && <ShortsSkeleton label={locale.loading} />}
|
|
{status.error && <div className="shorts-state warning">{status.error}</div>}
|
|
{!status.loading && !status.error && !items.length && <div className="shorts-state">{locale.empty}</div>}
|
|
<div className="shorts-scroll" ref={feedRef} aria-label={locale.shortTitle}>
|
|
{items.map((video) => (
|
|
<ShortSlide
|
|
key={video.id}
|
|
video={video}
|
|
active={video.id === activeId}
|
|
followed={followedChannels.includes(video.channel)}
|
|
onToggleFollow={() => toggleFollow(video.channel)}
|
|
onOpenComments={() => setCommentsVideo(video)}
|
|
onShare={() => shareVideo(video)}
|
|
/>
|
|
))}
|
|
</div>
|
|
{items.length > 1 && (
|
|
<div className="feed-arrows">
|
|
<button onClick={() => move(-1)} disabled={!activeIndex} aria-label="Previous video" type="button">↑</button>
|
|
<button onClick={() => move(1)} disabled={activeIndex >= items.length - 1} aria-label="Next video" type="button">↓</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
{commentsVideo && <CommentsSheet video={commentsVideo} locale={locale} onClose={() => setCommentsVideo(null)} />}
|
|
{toast.message && <div className="shorts-toast">{toast.message}</div>}
|
|
</>
|
|
);
|
|
}
|