refactor: 企业级目录重构与播放/关于页体验升级

- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件
- 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分
- 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈
- 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口
- 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题
- 语言按本地偏好与 IP 识别,国旗改为内联 SVG
- 清理未使用的旧站点文件,补充 ESLint 配置
This commit is contained in:
Zhou Weidong committed 2026-11-12 12:32:29 +08:00
1 parent 8dd5283892
commit ecfbd3b8b5
90 files changed
+4535 -886

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
// Author: fruit.com.cn
import { ArrowRight, Clapperboard, Cpu, Play, Smartphone, Sparkles, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { aboutContent } from './content';
const APP_DOWNLOAD_URL = 'https://cdnetworksdist.okrivo.xyz/wPwBG';
const PILLARS = [
{ key: 'join', icon: Users },
{ key: 'produce', icon: Clapperboard },
{ key: 'compute', icon: Cpu },
];
export function AboutPage() {
const { locale } = useLocale();
const copy = aboutContent[locale.code] || aboutContent.en;
return (
<div className="about-page">
<section className="about-hero">
<div className="about-hero-copy">
<span className="about-kicker">{copy.kicker}</span>
<h1>{copy.heroTitle}</h1>
<p>{copy.heroText}</p>
<div className="about-hero-actions">
<Link to="/" className="about-primary"><Sparkles size={16} />{copy.explore}</Link>
<Link to="/shorts" className="about-secondary"><Play size={16} fill="currentColor" />{copy.watchShorts}</Link>
</div>
<div className="about-stores">
<span className="about-stores-label">{copy.downloadLabel}</span>
<div className="about-stores-row">
<a className="about-store" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<Smartphone size={20} />
<span><small>{copy.iosSmall}</small><b>App Store</b></span>
</a>
<a className="about-store" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<Play size={20} fill="currentColor" />
<span><small>{copy.androidSmall}</small><b>Google Play</b></span>
</a>
</div>
</div>
<div className="about-values">{copy.values.map((value) => <span key={value}>{value}</span>)}</div>
</div>
<div className="about-hero-visual" aria-hidden="true">
<span className="about-float about-float-a">{copy.joinTitle}</span>
<span className="about-float about-float-b">{copy.produceTitle}</span>
<span className="about-float about-float-c">{copy.computeTitle}</span>
<div className="about-phone">
<span className="about-phone-glow" />
<span className="about-phone-logo">ymb</span>
<span className="about-phone-play"><Play size={26} fill="currentColor" /></span>
<small>SHORTS</small>
</div>
</div>
</section>
<section className="about-section">
<h2>{copy.whoTitle}</h2>
<p className="about-lead">{copy.whoText}</p>
</section>
<section className="about-section">
<h2>{copy.pillarsTitle}</h2>
<div className="about-pillars">
{PILLARS.map(({ key, icon: Icon }) => (
<article className="about-pillar" key={key}>
<span className="about-pillar-icon"><Icon size={22} /></span>
<h3>{copy[`${key}Title`]}</h3>
<p>{copy[`${key}Text`]}</p>
</article>
))}
</div>
</section>
<section className="about-section">
<h2>{copy.roadmapTitle}</h2>
<ol className="about-roadmap">
{copy.roadmap.map((step, index) => (
<li key={step.title}>
<span className="about-step-index">{String(index + 1).padStart(2, '0')}</span>
<div>
<b>{step.year} · {step.title}</b>
<p>{step.text}</p>
</div>
</li>
))}
</ol>
</section>
<section className="about-cta">
<div>
<h2>{copy.ctaTitle}</h2>
<p>{copy.ctaText}</p>
</div>
<Link to="/shorts" className="about-primary">{copy.ctaButton}<ArrowRight size={16} /></Link>
</section>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
// Author: fruit.com.cn
export const aboutContent = {
en: {
kicker: 'OFFICIAL YMB SITE',
heroTitle: 'Short stories. Big business.',
heroText: 'YMB (Your Mini Business) is the official home of the YMB micro-drama app — a mobile-first stage for bite-sized stories, and an open platform for the next generation of creators.',
explore: 'Explore dramas',
watchShorts: 'Watch Shorts',
downloadLabel: 'Get the YMB app',
iosSmall: 'Download on the',
androidSmall: 'Get it on',
values: ['Mobile-first', 'Multilingual', 'Open platform'],
whoTitle: 'Who we are',
whoText: 'YMB brings vertical, fast-paced dramas to audiences around the world. We localize stories across languages, connect them with the right viewers, and keep the experience light, mobile-first, and free to explore.',
pillarsTitle: 'Where YMB is going',
joinTitle: 'Creator onboarding',
joinText: 'Studios and independent creators will be able to join YMB, publish their work, and grow with the platform.',
produceTitle: 'Self-production',
produceText: 'Original YMB productions get the tools, distribution, and audience to turn a good story into a hit series.',
computeTitle: 'Computing power',
computeText: 'A future marketplace for rendering and AI compute — buy, sell, and allocate capacity for production.',
roadmapTitle: 'Roadmap',
roadmap: [
{ year: '2026', title: 'App launch', text: 'The YMB app goes live with a multilingual micro-drama library and a swipe-first Shorts feed.' },
{ year: 'Next', title: 'Creator onboarding', text: 'Open registration for creators and studios, with publishing and audience tools.' },
{ year: 'Next', title: 'Self-production', text: 'YMB Originals and partner productions, supported from script to screen.' },
{ year: 'Next', title: 'Compute marketplace', text: 'Sell or allocate rendering and AI compute directly inside the YMB ecosystem.' },
],
ctaTitle: 'Build the next hit with YMB.',
ctaText: 'Whether you create, produce, or power the next story — there is a place for you on YMB.',
ctaButton: 'Start exploring',
},
id: {
kicker: 'SITUS RESMI YMB',
heroTitle: 'Cerita pendek. Bisnis besar.',
heroText: 'YMB (Your Mini Business) adalah rumah resmi aplikasi drama pendek YMB — panggung mobile-first untuk cerita singkat, sekaligus platform terbuka bagi generasi kreator berikutnya.',
explore: 'Jelajahi drama',
watchShorts: 'Tonton Shorts',
downloadLabel: 'Unduh aplikasi YMB',
iosSmall: 'Unduh di',
androidSmall: 'Dapatkan di',
values: ['Mobile-first', 'Multibahasa', 'Platform terbuka'],
whoTitle: 'Siapa kami',
whoText: 'YMB menghadirkan drama vertikal yang cepat ke penonton di seluruh dunia. Kami melokalkan cerita, menghubungkannya dengan penonton yang tepat, dan menjaga pengalaman tetap ringan, mobile-first, dan gratis untuk dijelajahi.',
pillarsTitle: 'Arah YMB berikutnya',
joinTitle: 'Bergabung kreator',
joinText: 'Studio dan kreator independen akan dapat bergabung dengan YMB, mempublikasikan karya, dan tumbuh bersama platform.',
produceTitle: 'Produksi mandiri',
produceText: 'Produksi orisinal YMB mendapatkan alat, distribusi, dan audiens untuk mengubah cerita bagus menjadi serial hit.',
computeTitle: 'Daya komputasi',
computeText: 'Marketplace masa depan untuk rendering dan komputasi AI — beli, jual, dan alokasikan kapasitas produksi.',
roadmapTitle: 'Peta jalan',
roadmap: [
{ year: '2026', title: 'Peluncuran app', text: 'Aplikasi YMB hadir dengan pustaka drama multibahasa dan feed Shorts geser.' },
{ year: 'Berikutnya', title: 'Bergabung kreator', text: 'Pendaftaran terbuka untuk kreator dan studio, dengan alat publikasi dan audiens.' },
{ year: 'Berikutnya', title: 'Produksi mandiri', text: 'Produksi YMB Originals dan mitra, didukung dari naskah hingga layar.' },
{ year: 'Berikutnya', title: 'Marketplace komputasi', text: 'Jual atau alokasikan rendering dan komputasi AI langsung di ekosistem YMB.' },
],
ctaTitle: 'Bangun hit berikutnya bersama YMB.',
ctaText: 'Baik kamu kreator, produser, atau mitra komputasi — ada tempat untukmu di YMB.',
ctaButton: 'Mulai jelajahi',
},
zh: {
kicker: 'YMB 官方网站',
heroTitle: '短故事,大生意。',
heroText: 'YMB(Your Mini Business)微短剧 App 官网 —— 一个为竖屏短故事而生的移动舞台,也是一个向新一代创作者开放的平台。',
explore: '浏览短剧',
watchShorts: '观看 Shorts',
downloadLabel: '下载 YMB App',
iosSmall: '前往下载',
androidSmall: '前往获取',
values: ['移动优先', '多语言', '开放平台'],
whoTitle: '我们是谁',
whoText: 'YMB 把节奏明快、适合竖屏观看的短剧带给全球观众。我们做多语言本地化、内容分发与轻量化的移动观影体验,让好故事被更多人看到。',
pillarsTitle: 'YMB 的下一步',
joinTitle: '创作者入驻',
joinText: '影视公司与独立创作者将可以入驻 YMB,发布作品,并与平台一起成长。',
produceTitle: '自主制片',
produceText: 'YMB 自制内容将获得工具、分发与观众资源,把好故事做成爆款短剧。',
computeTitle: '算力出售',
computeText: '面向渲染与 AI 算力的未来市场,在 YMB 生态内买卖与调配算力。',
roadmapTitle: '发展路线',
roadmap: [
{ year: '2026', title: 'App 上线', text: 'YMB App 正式上线,提供多语言短剧库与竖滑 Shorts 信息流。' },
{ year: '下一步', title: '创作者入驻', text: '面向创作者与影视公司开放注册,提供发布与受众运营工具。' },
{ year: '下一步', title: '自主制片', text: 'YMB 自制与联合制片项目,从剧本到成片全程支持。' },
{ year: '下一步', title: '算力市场', text: '在 YMB 生态内直接出售或调配渲染与 AI 算力。' },
],
ctaTitle: '和 YMB 一起打造下一个爆款。',
ctaText: '无论你是创作者、制片方还是算力伙伴,YMB 都有你的位置。',
ctaButton: '开始探索',
},
};
+65
View File
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { HistoryGridSkeleton } from '../../shared/components/skeletons';
import { useMutation } from '../../shared/hooks/useMutation';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { watchApi } from '../../shared/api/watch';
import { normalizeProgress } from '../../entities/drama/model';
function episodeLabel(record, localeCode) {
if (!record.episodeNumber) return '';
return localeCode === 'zh' ? `第${record.episodeNumber}集` : `Episode ${record.episodeNumber}`;
}
export function HistoryPage() {
const { locale } = useLocale();
const history = useRequest(
({ signal }) => watchApi.recent(1, 20, { signal }).then((payload) => normalizePage(payload).list.map((item) => normalizeProgress(item, locale.code))),
[locale.code],
);
const clearHistory = useMutation(() => watchApi.clear());
const records = history.data;
const errorMessage = history.error?.message || clearHistory.error?.message || '';
const clearAll = async () => {
if (!window.confirm(`${locale.clearHistory}?`)) return;
try {
await clearHistory.run();
history.refetch();
} catch {
// 错误由 clearHistory.error 呈现
}
};
return (
<section className="history-page">
<div className="history-heading">
<h1>{locale.history}</h1>
{!!records?.length && <button type="button" onClick={clearAll}>{locale.clearHistory}</button>}
</div>
{history.loading && records === null && <HistoryGridSkeleton count={6} label={locale.loading} />}
{errorMessage && <div className="api-note warning">{errorMessage}</div>}
{records && !records.length && !errorMessage && <div className="api-note">{locale.historyEmpty}</div>}
{!!records?.length && (
<div className="video-grid">
{records.map((record) => {
const percent = Math.round(record.fraction * 100);
const meta = [episodeLabel(record, locale.code), record.durationMs > 0 ? `${percent}%` : ''].filter(Boolean).join(' · ');
return (
<article className="history-card" key={record.id}>
<Link to={`/watch/${record.id}`} className="history-thumb">
<img src={record.image} alt={record.title} loading="lazy" />
{record.durationMs > 0 && <span className="history-progress"><i style={{ width: `${percent}%` }} /></span>}
</Link>
<Link to={`/watch/${record.id}`} className="history-title">{record.title}</Link>
{meta && <small>{meta}</small>}
</article>
);
})}
</div>
)}
</section>
);
}
+129
View File
@@ -0,0 +1,129 @@
// Author: fruit.com.cn
import { useMemo, useState } from 'react';
import { useLocale } from '../../app/providers/AppProviders';
import { AsyncBoundary } from '../../shared/components/AsyncBoundary';
import { MediaCard } from '../../shared/components/MediaCard';
import { VideoGrid } from '../../shared/components/VideoGrid';
import { CategoryChipsSkeleton, ShortsShelfSkeleton, VideoGridSkeleton } from '../../shared/components/skeletons';
import { videos } from '../../shared/data/catalog';
import { searchCatalog } from '../../shared/data/catalogService';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { dramaApi } from '../../entities/drama/api';
import { categoryApi } from '../../entities/category/api';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { CategoryBar } from './components/CategoryBar';
const SHORTS_LIMIT = 5;
const FIRST_SHELF_LIMIT = 6;
const ALL_CATEGORY_ID = 'all';
const FALLBACK_GENRE_PREFIX = 'genre-';
function readTitle(locales, localeCode) {
return locales?.[localeCode] || locales?.en || locales?.id || locales?.zh || '';
}
function normalizeHomeItems(payload, localeCode) {
const sections = payload?.sections || [];
return uniqueDramas(sections.flatMap((section) => section.dramas || []).map((item) => normalizeDrama(item, localeCode)));
}
function normalizeCategoryItems(payload, localeCode) {
return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)));
}
function normalizeCategories(payload, localeCode) {
return (Array.isArray(payload) ? payload : [])
.map((item) => ({
id: String(item.id ?? ''),
label: readTitle(item.locales, localeCode) || item.code || '',
}))
.filter((item) => item.id && item.label);
}
function fallbackGenreIndex(categoryId) {
const matched = /^genre-(\d+)$/.exec(categoryId);
return matched ? Number(matched[1]) : 0;
}
export function HomePage() {
const { locale } = useLocale();
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_ID);
const isAllCategory = activeCategory === ALL_CATEGORY_ID;
const feedRequest = useRequest(
({ signal }) => dramaApi.homeFeed({ signal }).then((payload) => normalizeHomeItems(payload, locale.code)),
[locale.code],
);
const categoriesRequest = useRequest(
({ signal }) => categoryApi.list({ signal }).then((payload) => normalizeCategories(payload, locale.code)),
[locale.code],
);
const categoryRequest = useRequest(
({ signal }) => categoryApi.dramas(activeCategory, 1, 20, { signal }).then((payload) => normalizeCategoryItems(payload, locale.code)),
[activeCategory, locale.code],
{ enabled: !isAllCategory },
);
const homeItems = feedRequest.data || [];
const serverCategories = useMemo(() => categoriesRequest.data || [], [categoriesRequest.data]);
const fallbackCategories = useMemo(
() => locale.genres.slice(1).map((label, index) => ({ id: `${FALLBACK_GENRE_PREFIX}${index + 1}`, label })),
[locale.genres],
);
const categories = useMemo(() => {
const allCategory = { id: ALL_CATEGORY_ID, label: locale.genres[0] };
if (serverCategories.length) return [allCategory, ...serverCategories];
return categoriesRequest.loading ? [allCategory] : [allCategory, ...fallbackCategories];
}, [categoriesRequest.loading, fallbackCategories, locale.genres, serverCategories]);
const fallbackItems = useMemo(() => {
const genreIndex = fallbackGenreIndex(activeCategory);
return searchCatalog('', locale.genres[genreIndex], genreIndex);
}, [activeCategory, locale.genres]);
const loading = isAllCategory ? feedRequest.loading : categoryRequest.loading;
const error = isAllCategory ? feedRequest.error : categoryRequest.error;
const remoteItems = isAllCategory ? homeItems : (categoryRequest.data || []);
const items = error ? fallbackItems : remoteItems;
const shortsItems = (homeItems.length ? homeItems : videos).slice(0, SHORTS_LIMIT);
const firstShelf = items.slice(0, FIRST_SHELF_LIMIT);
const secondShelf = items.slice(FIRST_SHELF_LIMIT);
return (
<>
{categoriesRequest.loading && !serverCategories.length ? <CategoryChipsSkeleton /> : (
<CategoryBar categories={categories} activeId={activeCategory} onChange={setActiveCategory} />
)}
<section className="youtube-home-feed" aria-label={locale.home}>
{error && <div className="api-note warning">{locale.apiFallback}</div>}
<AsyncBoundary
loading={loading && !items.length}
skeleton={<VideoGridSkeleton count={FIRST_SHELF_LIMIT} label={locale.loading} />}
empty={!loading && !error && !items.length}
emptyMessage={locale.empty}
>
<VideoGrid items={firstShelf} />
</AsyncBoundary>
</section>
<section className="youtube-shorts-shelf" aria-labelledby="shorts-title">
<div className="youtube-shelf-heading">
<h2 id="shorts-title"><span className="shorts-mark">▶</span>{locale.shortTitle}</h2>
<button type="button">{locale.explore}</button>
</div>
{feedRequest.loading && !homeItems.length ? <ShortsShelfSkeleton count={SHORTS_LIMIT} label={locale.loading} /> : (
<div className="shorts-row" aria-label={locale.shortTitle}>
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact to={`/shorts?start=${video.id}`} />)}
</div>
)}
</section>
{secondShelf.length > 0 && (
<section className="youtube-home-feed youtube-home-feed-secondary" aria-label={locale.picks}>
<VideoGrid items={secondShelf} />
</section>
)}
</>
);
}
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
export function CategoryBar({ categories, activeId, onChange }) {
return (
<div className="youtube-category-bar" role="tablist" aria-label="Categories">
{categories.map((category) => (
<button
className={`youtube-category-chip ${activeId === category.id ? 'active' : ''}`}
key={category.id}
type="button"
role="tab"
aria-selected={activeId === category.id}
onClick={() => onChange(category.id)}
>
{category.label}
</button>
))}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
import { useLocale } from '../../app/providers/AppProviders';
import { SectionHeading } from '../../shared/components/SectionHeading';
import { VideoGrid } from '../../shared/components/VideoGrid';
import { VideoGridSkeleton } from '../../shared/components/skeletons';
import { videos } from '../../shared/data/catalog';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { listingApi } from './api';
export function ListingPage({ type }) {
const { locale } = useLocale();
const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library;
const request = useRequest(
({ signal }) => listingApi.dramas({}, { signal }).then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))),
[locale.code],
);
const items = request.data?.length ? request.data : videos;
return (
<>
<SectionHeading title={title} />
{request.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
<>
{request.error && <div className="api-note warning">{locale.apiFallback}</div>}
<VideoGrid items={items} />
</>
)}
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Author: fruit.com.cn
import { http } from '../../shared/api';
export const listingApi = {
dramas: (body = {}, options) => http.post('/app/dramas/page', {
pageIndex: 1,
pageSize: 20,
categoryIds: [],
classificationIds: [],
tagIds: [],
...body,
}, options),
};
+49
View File
@@ -0,0 +1,49 @@
// Author: fruit.com.cn
import { Search as SearchIcon } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { SectionHeading } from '../../shared/components/SectionHeading';
import { VideoGrid } from '../../shared/components/VideoGrid';
import { VideoGridSkeleton } from '../../shared/components/skeletons';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { dramaApi } from '../../entities/drama/api';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { searchApi } from './api';
async function loadSearchResults(keyword, localeCode, signal) {
if (keyword) {
const payload = await searchApi.search(keyword, { signal });
return normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode));
}
const payload = await dramaApi.homeFeed({ signal });
const items = payload?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
}
export function SearchPage() {
const { locale } = useLocale();
const [params] = useSearchParams();
const query = (params.get('q') || '').trim();
const request = useRequest(({ signal }) => loadSearchResults(query, locale.code, signal), [query, locale.code]);
const results = request.data || [];
return (
<>
<SectionHeading title={query ? `${locale.searchResults} “${query}”` : locale.picks} />
{request.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
<>
{request.error && <div className="api-note warning">{locale.apiFallback}</div>}
{!request.error && query && results.length === 0 ? (
<div className="search-empty">
<SearchIcon size={28} />
<b>{locale.noResults} “{query}”</b>
</div>
) : (
<VideoGrid items={results} />
)}
</>
)}
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
// Author: fruit.com.cn
import { http } from '../../shared/api';
import { toQuery } from '../../shared/api/query';
export const searchApi = {
search: (keyword, options) => http.get(`/app/search${toQuery({ keyword, pageIndex: 1, pageSize: 48 })}`, options),
};
+156
View File
@@ -0,0 +1,156 @@
// 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>}
</>
);
}
@@ -0,0 +1,62 @@
// Author: fruit.com.cn
import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
import { useLocale } from '../../../app/providers/AppProviders';
import { VideoPlayer } from '../../../shared/components/player/VideoPlayer';
import { useEngagement } from '../../../shared/hooks/useEngagement';
import { formatCount } from '../../../shared/lib/format';
import { reportWatchProgress } from '../../../shared/lib/watchProgress';
export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComments, onShare }) {
const { locale } = useLocale();
const src = video.preview?.playUrl;
const likeTargetId = video.preview?.id || video.id;
const { liked, likeCount, saved, saveCount, toggleLike, toggleSave } = useEngagement({
likeTargetType: video.preview?.id ? 'EPISODE' : 'DRAMA',
likeTargetId,
dramaId: video.id,
initialLikeCount: video.likeCount,
initialSaveCount: video.favoriteCount,
});
return (
<article className="short-slide" data-id={video.id}>
<div className="short-slide-stage">
<div className="short-slide-info">
<div className="short-channel">
<span className="short-avatar">{video.channel?.[0] || 'Y'}</span>
<span className="short-channel-name">{video.channel}</span>
<button className={`short-follow ${followed ? 'followed' : ''}`} onClick={onToggleFollow} type="button">{followed ? locale.subscribed : locale.subscribe}</button>
</div>
<h1>{video.title}</h1>
</div>
<div className="short-column">
<div className="short-player-wrap">
<div className="short-slide-player">
{src
? <VideoPlayer src={src} poster={video.image} autoPlay={active} onProgress={(position, duration, flush) => reportWatchProgress(video.preview?.id, position, duration, 'shorts', flush)} />
: <div className="short-slide-placeholder"><img src={video.image} alt={video.title} /><span>{video.title}</span></div>}
</div>
</div>
</div>
<div className="short-rail">
<button className={saved ? 'active' : ''} onClick={toggleSave} type="button" aria-label={locale.save}>
<span className="rail-icon"><Bookmark size={22} fill={saved ? 'currentColor' : 'none'} /></span>
<small>{formatCount(saveCount, locale.code)}</small>
</button>
<button className={liked ? 'active' : ''} onClick={toggleLike} type="button" aria-label={locale.like}>
<span className="rail-icon"><ThumbsUp size={22} fill={liked ? 'currentColor' : 'none'} /></span>
<small>{formatCount(likeCount, locale.code)}</small>
</button>
<button onClick={onOpenComments} type="button" aria-label={locale.comments}>
<span className="rail-icon"><MessageCircle size={22} /></span>
<small>{formatCount(video.commentCount, locale.code)}</small>
</button>
<button onClick={onShare} type="button" aria-label={locale.share}>
<span className="rail-icon"><Share2 size={22} /></span>
<small>{locale.share}</small>
</button>
</div>
</div>
</article>
);
}
+216
View File
@@ -0,0 +1,216 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
import { useLocale } from '../../app/providers/AppProviders';
import { CommentsSheet } from '../../shared/components/CommentsSheet';
import { VideoPlayer } from '../../shared/components/player/VideoPlayer';
import { WatchPageSkeleton } from '../../shared/components/skeletons';
import { useEngagement } from '../../shared/hooks/useEngagement';
import { useFollowedChannels } from '../../shared/hooks/useFollowedChannels';
import { useRequest } from '../../shared/hooks/useRequest';
import { useToast } from '../../shared/hooks/useToast';
import { formatCount, formatViews } from '../../shared/lib/format';
import { selectPlaybackUrl } from '../../shared/lib/media';
import { reportWatchProgress } from '../../shared/lib/watchProgress';
import { normalizeDrama } from '../../entities/drama/model';
import { dramaApi } from '../../entities/drama/api';
import { EpisodeRail, episodeLabel } from './components/EpisodeRail';
function collectEpisodes(video) {
if (!video.episodes?.length) return video.preview ? [video.preview] : [];
const hasPreview = video.preview && !video.episodes.some((item) => item.id === video.preview.id);
return hasPreview ? [video.preview, ...video.episodes] : video.episodes;
}
function WatchContent({ video }) {
const { locale } = useLocale();
const episodes = collectEpisodes(video);
const [episodeId, setEpisodeId] = useState(video.preview?.id || episodes[0]?.id);
const episodeIndex = Math.max(0, episodes.findIndex((item) => item.id === episodeId));
const currentEpisode = episodes[episodeIndex] || episodes[0] || null;
const nextEpisode = episodes[episodeIndex + 1];
const previousEpisode = episodes[episodeIndex - 1];
const toast = useToast();
const playback = useRequest(
({ signal }) => dramaApi.playback(currentEpisode.id, { signal }),
[currentEpisode?.id],
{ enabled: Boolean(currentEpisode?.id) && !currentEpisode?.locked },
);
const stats = useRequest(
({ signal }) => dramaApi.stats(currentEpisode.id, { signal }),
[currentEpisode?.id],
{ enabled: Boolean(currentEpisode?.id) },
);
const engagement = useEngagement({
likeTargetType: 'EPISODE',
likeTargetId: currentEpisode?.id,
dramaId: video.id,
initialLikeCount: currentEpisode?.likeCount,
initialSaveCount: video.favoriteCount,
});
const { channels, toggle } = useFollowedChannels();
const followed = channels.includes(video.channel);
const [commentsOpen, setCommentsOpen] = useState(false);
const swipeStart = useRef(null);
const playerRef = useRef(null);
const wheelRef = useRef({ accum: 0, locked: false, lastAt: 0, lastAbs: 0 });
useEffect(() => {
engagement.syncStats(stats.data);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stats.data]);
const changeEpisode = (episode, scroll = false) => {
if (!episode || episode.id === currentEpisode?.id) return;
setEpisodeId(episode.id);
if (scroll) playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const goNext = () => changeEpisode(nextEpisode);
const goPrevious = () => changeEpisode(previousEpisode);
useEffect(() => {
// 捕获阶段接管画面内的滚轮,任何幅度都不交给页面滚动
const onWheel = (event) => {
if (!event.deltaY) return;
// 用坐标判断是否在播放区内:切集后 video 节点会被替换,事件 target 可能失效
const rect = playerRef.current?.getBoundingClientRect();
const inside = rect
? event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom
: Boolean(event.target?.closest?.('.watch-stage'));
if (!inside) return;
event.preventDefault();
const state = wheelRef.current;
const now = performance.now();
// 兼容按行滚动的设备(Firefox 等),统一成像素
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
const magnitude = Math.abs(delta);
// 新手势:停顿超过 60ms,或惯性衰减中突然重新加速(新的一次滑动)
if (now - state.lastAt > 60 || magnitude > state.lastAbs * 2.2 + 8) state.locked = false;
state.lastAt = now;
state.lastAbs = magnitude;
if (state.accum && Math.sign(delta) !== Math.sign(state.accum)) state.accum = delta;
else state.accum += delta;
if (state.locked || Math.abs(state.accum) < 24) return;
const forward = state.accum > 0;
if (forward ? !nextEpisode : !previousEpisode) return;
state.locked = true;
state.accum = 0;
if (forward) goNext(); else goPrevious();
};
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
return () => window.removeEventListener('wheel', onWheel, { capture: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nextEpisode, previousEpisode]);
if (!currentEpisode) return <div className="api-note warning">{locale.playbackUnavailable}</div>;
const src = currentEpisode.locked ? '' : selectPlaybackUrl(playback.data, currentEpisode.playUrl);
const views = formatViews(video.views, locale.code);
const tag = video.genre ? `#${video.genre.replace(/\s+/g, '')}` : '';
const commentCount = Number(stats.data?.commentCount ?? currentEpisode.commentCount) || 0;
const label = episodeLabel(currentEpisode, episodeIndex, locale.code);
const commentVideo = { ...video, preview: currentEpisode };
const onTouchStart = (event) => {
const touch = event.touches[0];
swipeStart.current = { x: touch.clientX, y: touch.clientY };
};
const onTouchEnd = (event) => {
const start = swipeStart.current;
swipeStart.current = null;
if (!start) return;
const touch = event.changedTouches[0];
const dx = start.x - touch.clientX;
const dy = start.y - touch.clientY;
if (Math.abs(dy) < 50 || Math.abs(dx) > Math.abs(dy)) return;
if (dy > 0) goNext(); else goPrevious();
};
const share = async () => {
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);
}
};
return (
<div className="watch-page">
<div className="watch-primary">
<div ref={playerRef} className="watch-stage" style={{ '--watch-poster': `url("${video.image}")` }}>
<div className="watch-player" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
{src
? <VideoPlayer key={currentEpisode.id} src={src} poster={video.image} autoPlay onProgress={(position, duration, flush) => reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} />
: <div className="watch-player-placeholder"><img src={video.image} alt={video.title} /><span>{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error?.message || locale.playbackUnavailable}</span></div>}
<span className="watch-swipe-tip">{locale.episodeSwipeHint}</span>
<div className="watch-arrows">
<button onClick={goPrevious} disabled={!previousEpisode} aria-label="Previous episode" type="button">↑</button>
<button onClick={goNext} disabled={!nextEpisode} aria-label="Next episode" type="button">↓</button>
</div>
</div>
</div>
<div className="watch-meta">
<span className="watch-badge">{label}</span>
<span>{views}{tag ? ` · ${tag}` : ''}</span>
</div>
<h1 className="watch-title">{video.title}</h1>
<div className="watch-toolbar">
<div className="watch-channel">
<span className="watch-avatar">{video.channel?.[0] || 'Y'}</span>
<div className="watch-channel-copy"><b>{video.channel}</b></div>
<button className={`watch-subscribe ${followed ? 'followed' : ''}`} onClick={() => toggle(video.channel)} type="button">{followed ? locale.subscribed : locale.subscribe}</button>
</div>
<div className="watch-actions">
<button className={engagement.liked ? 'active' : ''} onClick={engagement.toggleLike} type="button"><ThumbsUp size={18} fill={engagement.liked ? 'currentColor' : 'none'} /><span>{formatCount(engagement.likeCount, locale.code)}</span></button>
<button className={engagement.saved ? 'active' : ''} onClick={engagement.toggleSave} type="button"><Bookmark size={18} fill={engagement.saved ? 'currentColor' : 'none'} /><span>{formatCount(engagement.saveCount, locale.code)}</span></button>
<button onClick={() => setCommentsOpen(true)} type="button"><MessageCircle size={18} /><span>{formatCount(commentCount, locale.code)}</span></button>
<button onClick={share} type="button"><Share2 size={18} /><span>{locale.share}</span></button>
</div>
</div>
{video.description && (
<div className="watch-description">
<p>{video.description}</p>
</div>
)}
</div>
<EpisodeRail episodes={episodes} currentId={currentEpisode.id} image={video.image} onSelect={(episode) => changeEpisode(episode, true)} />
{commentsOpen && <CommentsSheet video={commentVideo} locale={locale} onClose={() => setCommentsOpen(false)} />}
{toast.message && <div className="shorts-toast">{toast.message}</div>}
</div>
);
}
export function WatchPage() {
const { id } = useParams();
const { locale } = useLocale();
const drama = useRequest(
({ signal }) => dramaApi.get(id, { signal }).then((data) => normalizeDrama(data, locale.code)),
[id, locale.code],
);
useEffect(() => {
window.scrollTo({ top: 0 });
const timer = window.setTimeout(() => window.scrollTo({ top: 0 }), 0);
return () => window.clearTimeout(timer);
}, [id]);
if (!drama.data && drama.loading) return <WatchPageSkeleton label={locale.loading} />;
if (!drama.data) return <div className="api-note warning">{drama.error?.message || locale.empty}</div>;
return <WatchContent key={drama.data.id} video={drama.data} />;
}
@@ -0,0 +1,43 @@
// Author: fruit.com.cn
import { Lock } from 'lucide-react';
import { useLocale } from '../../../app/providers/AppProviders';
export function episodeLabel(episode, index, code) {
const number = episode?.number ?? index + 1;
return code === 'zh' ? `第${number}集` : `Episode ${number}`;
}
export function EpisodeRail({ episodes, currentId, image, onSelect }) {
const { locale } = useLocale();
if (!episodes.length) return null;
const currentIndex = Math.max(0, episodes.findIndex((episode) => episode.id === currentId));
return (
<aside className="watch-side">
<h2>
{locale.episodes}
<span className="watch-episode-count">{currentIndex + 1} / {episodes.length}</span>
</h2>
<div className="watch-episode-list">
{episodes.map((episode, index) => (
<button
className={`watch-episode-item ${episode.id === currentId ? 'active' : ''}`}
key={episode.id}
onClick={() => onSelect(episode)}
type="button"
>
<span className="watch-episode-thumb">
<img src={image} alt="" loading="lazy" />
<b>{episode.number ?? index + 1}</b>
{episode.locked && <span className="watch-episode-lock"><Lock size={16} /></span>}
</span>
<span className="watch-episode-copy">
<b>{episodeLabel(episode, index, locale.code)}</b>
<small>{episode.duration || ''}</small>
</span>
</button>
))}
</div>
</aside>
);
}