refactor: 企业级目录重构与播放/关于页体验升级
- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件 - 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分 - 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈 - 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口 - 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题 - 语言按本地偏好与 IP 识别,国旗改为内联 SVG - 清理未使用的旧站点文件,补充 ESLint 配置
No files matched your search
@@ -8,17 +8,22 @@ YMB 公开站点 React + Vite 项目,参考 YouTube 的内容发现信息架
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app/ # 路由与全局 Store
|
||||
├── layouts/ # AppShell、顶部栏、侧栏、移动端导航
|
||||
├── pages/ # Home、Shorts、Subscriptions、Library、History、Search、Watch
|
||||
├── components/ # MediaCard、SectionHeading 等复用组件
|
||||
├── features/ # 后续业务域扩展入口
|
||||
├── services/ # 目录检索与接口边界
|
||||
├── data/ # 演示目录数据
|
||||
├── i18n/ # ID / EN / zh-CN 语言包
|
||||
└── lib/seo.js # 页面 SEO 元数据入口
|
||||
├── app/ # 应用装配:路由(懒加载)、Locale/UI Provider
|
||||
├── shared/ # 与业务无关的通用能力
|
||||
│ ├── api/ # fetch 客户端、拦截器、错误类型、领域无关接口
|
||||
│ ├── hooks/ # useRequest / useMutation / useToast / useEngagement
|
||||
│ ├── components/ # AsyncBoundary、VideoGrid、MediaCard、骨架屏
|
||||
│ ├── lib/ # format、media、pagination、seo、watchProgress
|
||||
│ ├── data/ # 接口不可用时的演示数据
|
||||
│ └── styles/ # base / enterprise / 组件覆盖样式
|
||||
├── entities/ # 领域模型与接口(drama、category)
|
||||
├── features/ # 业务域:home / shorts / watch / search / history / listing / about
|
||||
├── layouts/ # AppShell + TopBar / SearchBox / Sidebar / BottomNav
|
||||
└── i18n/ # ID / EN / zh-CN 语言包
|
||||
```
|
||||
|
||||
依赖方向:`features → entities → shared`,`layouts → shared`;`shared` 不允许反向依赖业务域。
|
||||
|
||||
## YouTube 对照的信息架构
|
||||
|
||||
- 顶部 AppBar:品牌、全局搜索、语言、通知、用户入口。
|
||||
@@ -43,4 +48,4 @@ pnpm build
|
||||
- 多语言:印尼语 `?lang=id`、English `?lang=en`、简体中文 `?lang=zh`。
|
||||
- SEO:动态 `lang/title/description`、canonical、hreflang、Open Graph、Twitter Card、JSON-LD、robots.txt、sitemap.xml。
|
||||
|
||||
部署前请将 `ymb.example.com` 替换为正式域名,并同步修改 `index.html`、`app.js`、`robots.txt` 与 `sitemap.xml`。
|
||||
部署前请将 `ymb.example.com` 替换为正式域名,并同步修改 `index.html`、`public/robots.txt` 与 `public/sitemap.xml`。
|
||||
@@ -1,2 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
(function(){'use strict';const d=window.YMB_DATA;const params=new URLSearchParams(location.search);const s={lang:d.locales[params.get('lang')]?params.get('lang'):'id',genre:'',query:params.get('q')||''};const $=(q)=>document.querySelector(q);const t=()=>d.locales[s.lang];const notify=(m)=>{const toast=$('#toast');toast.textContent=m;toast.classList.add('show');setTimeout(()=>toast.classList.remove('show'),1600)};const setMeta=()=>{const locale=t();document.documentElement.lang=locale.lang;document.title=locale.title;$('#page-title').textContent=locale.title;$('#meta-description').content=locale.description;$('#canonical-link').href=`https://ymb.example.com/?lang=${s.lang}`;$('#og-title').content=locale.title;$('#og-description').content=locale.description;$('#twitter-title').content=locale.title;$('#twitter-description').content=locale.description;$('#structured-data').textContent=JSON.stringify({'@context':'https://schema.org','@type':'WebSite',name:'YMB',inLanguage:locale.lang,url:`https://ymb.example.com/?lang=${s.lang}`,description:locale.description,potentialAction:{'@type':'SearchAction',target:`https://ymb.example.com/?lang=${s.lang}&q={search_term_string}`,'query-input':'required name=search_term_string'}});$('#global-search').placeholder=locale.search;$('#language-select').value=s.lang;document.querySelectorAll('[data-i18n]').forEach((node)=>{const key=node.dataset.i18n;if(locale[key])node.textContent=locale[key]})};const updateUrl=()=>{const query=new URLSearchParams({lang:s.lang});if(s.query)query.set('q',s.query);history.replaceState(null,'',`?${query.toString()}`)};const render=()=>{const locale=t();const genres=locale.genres;const activeGenre=s.genre?d.locales.id.genres[genres.indexOf(s.genre)]:'';$('#genre-nav').innerHTML=genres.slice(1).map((g)=>`<a href="#home" data-genre="${g}">${g}</a>`).join('');$('#filter-chips').innerHTML=genres.map((g)=>`<button class="chip ${g===s.genre?'active':''}" data-genre="${g}">${g}</button>`).join('');const rows=d.videos.filter((v)=>(!activeGenre||v[2]===activeGenre)&&(!s.query||v.join(' ').toLowerCase().includes(s.query)));$('#video-grid').innerHTML=rows.map((v,i)=>`<article class="video-card" data-play="${v[0]}"><div class="thumb"><img src="${v[3]}" alt="${v[0]}" loading="lazy"><span class="duration">${v[4]}</span></div><div class="video-meta"><span class="channel-avatar">${v[1][0]}</span><div><p class="video-title">${v[0]}</p><p class="video-sub">${v[1]}<br>${(i+4)*137} rb tayangan · ${i+1} hari lalu</p></div></div></article>`).join('')||`<p>${locale.empty}</p>`;$('#shorts-row').innerHTML=d.shorts.map((v)=>`<article class="short-card" data-play="${v[0]}"><img src="${v[1]}" alt="${v[0]}" loading="lazy"><span class="short-title">${v[0]}</span></article>`).join('');$('#channel-grid').innerHTML=d.channels.map((v)=>`<article class="channel-card"><span class="channel-avatar" style="background:${v[3]}">${v[2]}</span><div><p>${v[0]}</p><span>${v[1]}</span></div><button class="follow-button" data-follow>${locale.follow}</button></article>`).join('');setMeta()};document.addEventListener('click',(e)=>{const genre=e.target.closest('[data-genre]');if(genre){e.preventDefault();s.genre=genre.dataset.genre;render()}const play=e.target.closest('[data-play]');if(play)notify(`${t().playing}${play.dataset.play}`);const follow=e.target.closest('[data-follow]');if(follow){const isFollowing=follow.textContent===t().follow;follow.textContent=isFollowing?t().following:t().follow;notify(isFollowing?t().added:t().removed)}if(e.target.closest('#menu-toggle'))$('#sidebar').classList.toggle('open')});$('#search-form').addEventListener('submit',(e)=>{e.preventDefault();s.query=$('#global-search').value.trim().toLowerCase();updateUrl();render()});$('#language-select').addEventListener('change',(e)=>{s.lang=e.target.value;s.genre='';updateUrl();render()});$('#global-search').value=s.query;render()})();
|
||||
@@ -1,11 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
window.YMB_DATA={
|
||||
locales:{
|
||||
id:{lang:'id',title:'YMB · Cerita pendek, emosi besar',description:'YMB — cerita pendek, emosi besar. Temukan drama pendek terbaik dan episode baru setiap hari.',search:'Cari drama, aktor, atau genre',original:'YMB ORIGINAL',heroTitle:'Cerita pendek,',heroAccent:'emosi besar.',heroDescription:'Temukan drama yang bikin kamu terus menonton. Episode baru setiap hari.',watchNow:'▶ Mulai menonton',save:'♡ Simpan',recommendation:'REKOMENDASI UNTUKMU',weeklyPicks:'Drama pilihan minggu ini',seeAll:'Lihat semua →',shortsTitle:'Cerita dalam satu tarikan napas',explore:'Jelajahi →',channels:'CHANNEL UNTUKMU',followStories:'Ikuti cerita favoritmu',manage:'Kelola →',follow:'Ikuti',following:'Mengikuti',added:'Channel ditambahkan',removed:'Channel dihapus',playing:'Memutar: ',empty:'Belum ada drama yang cocok.',genres:['Semua','Romansa','Balas dendam','Keluarga','Rahasia','Kisah kedua']},
|
||||
en:{lang:'en',title:'YMB · Short stories, big emotions',description:'YMB — short stories, big emotions. Discover the best short dramas and new episodes every day.',search:'Search dramas, actors, or genres',original:'YMB ORIGINAL',heroTitle:'Short stories,',heroAccent:'big emotions.',heroDescription:'Find dramas that keep you watching. New episodes every day.',watchNow:'▶ Watch now',save:'♡ Save',recommendation:'RECOMMENDED FOR YOU',weeklyPicks:'Drama picks this week',seeAll:'See all →',shortsTitle:'Stories in one breath',explore:'Explore →',channels:'CHANNELS FOR YOU',followStories:'Follow your favorite stories',manage:'Manage →',follow:'Follow',following:'Following',added:'Channel followed',removed:'Channel unfollowed',playing:'Playing: ',empty:'No dramas match your search.',genres:['All','Romance','Revenge','Family','Secrets','Second chance']},
|
||||
zh:{lang:'zh-CN',title:'YMB · 短故事,大情绪',description:'YMB——短故事,大情绪。发现精彩短剧,每天更新新剧集。',search:'搜索短剧、演员或题材',original:'YMB 原创',heroTitle:'短故事,',heroAccent:'大情绪。',heroDescription:'发现让你停不下来的短剧,每天都有新集数。',watchNow:'▶ 立即观看',save:'♡ 收藏',recommendation:'为你推荐',weeklyPicks:'本周精选短剧',seeAll:'查看全部 →',shortsTitle:'一口气看完的故事',explore:'去探索 →',channels:'为你推荐的频道',followStories:'关注你喜欢的故事',manage:'管理 →',follow:'关注',following:'已关注',added:'已关注频道',removed:'已取消关注',playing:'正在播放:',empty:'没有找到匹配的短剧。',genres:['全部','爱情','复仇','家庭','秘密','重生']}
|
||||
},
|
||||
videos:[['Pernikahan yang Tak Terduga','YMB Originals','Romansa','wedding.jpg','12:48'],['Rahasia di Balik Senyuman','Cerita Senja','Rahasia','secret.jpg','09:32'],['Pewaris yang Kembali','Drama Pilihan','Keluarga','heir.jpg','15:20'],['Kesempatan Kedua untuk Kita','YMB Originals','Kisah kedua','second-chance.jpg','11:04'],['Cinta Setelah Balas Dendam','Cerita Senja','Balas dendam','revenge.jpg','13:40'],['Sahabat yang Selalu Ada','Drama Pilihan','Keluarga','friends.jpg','08:16'],['Kota Kecil, Rahasia Besar','YMB Originals','Rahasia','hero.jpg','10:12'],['Satu Janji Terakhir','Drama Pilihan','Romansa','romance.jpg','07:55']],
|
||||
shorts:[['Dia kembali setelah 5 tahun','second-chance.jpg'],['Ketika rahasia terbongkar','secret.jpg'],['Satu pesan yang mengubah semuanya','romance.jpg'],['Jangan percaya senyumnya','revenge.jpg'],['Pewaris tanpa nama','heir.jpg'],['Hari pernikahan itu','wedding.jpg']],
|
||||
channels:[['YMB Originals','2,4 jt pengikut','Y','#ca4d43'],['Cerita Senja','986 rb pengikut','C','#5c4aa1'],['Drama Pilihan','742 rb pengikut','D','#2e7d71'],['Kisah Hari Ini','428 rb pengikut','K','#a06a39']]
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
# YMB Site 企业级重构方案
|
||||
|
||||
> 目标:在不改变现有视觉与交互的前提下,把「演示工程」重构成可按业务域扩展的前端工程。
|
||||
> 约束:纯重构,功能与文案不变;每个阶段结束必须 `pnpm build` + `pnpm lint` 通过;剧集 ID 保持字符串(19 位长整型)。
|
||||
|
||||
## 一、现状诊断
|
||||
|
||||
### 1. 目录没有业务边界
|
||||
|
||||
当前 `src/` 按技术类型平铺:`pages/`、`components/`、`services/`、`hooks/`、`lib/`、`data/`。
|
||||
- 首页、Shorts、播放页、搜索页共享同一批 `services` 与 `components`,无法知道改一个页面会影响谁。
|
||||
- 页面与接口调用直接耦合:`HomePage`、`SearchPage`、`ListingPage` 各自写一遍 `normalizePage(...).list.map(normalizeDrama)`。
|
||||
- `data/catalog.js` 是演示兜底数据,和真实 API 层混在同一层级。
|
||||
|
||||
### 2. 请求层只有 24 行,没有封装
|
||||
|
||||
`src/services/httpClient.js` 现状:
|
||||
- header、超时、JSON 解析、业务码判断全部内联在一个 `request()` 里;
|
||||
- 错误统一 `throw new Error(payload.message)`,丢失 HTTP 状态、业务码、请求路径,超时/断网/业务失败无法区分;
|
||||
- 没有拦截器链,后续加 token、埋点、错误上报只能继续往里堆 if;
|
||||
- `appApi.js` 用字符串拼 query(`?pageIndex=1&pageSize=20`),16 个端点挤在一个对象里。
|
||||
|
||||
### 3. 数据与状态层薄弱
|
||||
|
||||
- `useApi` 只有 `active` 标志,没有 `AbortController`、没有 `refetch`、没有重试;错误只有一个 `message` 字符串。
|
||||
- 点赞/收藏在 `ShortsFeedPage` 和 `WatchPage` 各写了一遍(含 try/catch + console.error),没有 mutation 抽象。
|
||||
- `store.jsx` 把 locale / 搜索词 / 侧栏状态塞进一个 Context,搜索框每次输入都会让整棵树重渲染。
|
||||
- `normalizers.js` 同时承担媒体 URL 解析、分页解包、播放进度模型,职责混杂。
|
||||
|
||||
### 4. 组件没有抽离
|
||||
|
||||
| 现状 | 问题 |
|
||||
| --- | --- |
|
||||
| `layouts/AppShell.jsx` 134 行 | 顶栏 + 搜索 + 创建菜单 + 侧栏 + 底部导航 + 上传弹窗状态全在一个组件 |
|
||||
| `pages/ShortsFeedPage.jsx` 237 行 | `ShortSlide`、点赞/收藏、分页游标、滚动监听、键盘事件混在一起 |
|
||||
| `pages/WatchPage.jsx` 235 行 | 播放源、滑动手势、剧集列表、统计、评论、分享混在一起 |
|
||||
| 各页面 | loading / error / empty 三态重复手写 |
|
||||
| `components/Skeletons.jsx` 104 行 | 6 个骨架屏塞在一个文件 |
|
||||
|
||||
### 5. 样式与路由
|
||||
|
||||
- `src/styles.css`(旧版全站样式,含 `@font-face`)、`enterprise.css`、`youtube-overrides.css` 三层叠加,没有 tokens 与层级约定。
|
||||
- `src/app/App.jsx` 单行全量 import,无懒加载;构建主 chunk 907KB,Vite 已提示超限。
|
||||
|
||||
## 二、目标目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app/ # 应用装配层
|
||||
│ ├── App.jsx # 只做 Provider + Router 组装
|
||||
│ ├── router.jsx # 路由表(React.lazy + Suspense)
|
||||
│ └── providers/
|
||||
│ ├── AppProviders.jsx
|
||||
│ ├── LocaleProvider.jsx # 语言 + SEO
|
||||
│ └── UiProvider.jsx # 侧栏 / 搜索词 / toast
|
||||
├── shared/ # 与业务无关的通用能力
|
||||
│ ├── api/
|
||||
│ │ ├── config.js # baseURL、超时、平台常量
|
||||
│ │ ├── errors.js # ApiError / TimeoutError / NetworkError
|
||||
│ │ ├── client.js # fetch 核心 + 拦截器管线
|
||||
│ │ ├── interceptors.js # 设备头 / JSON / 业务码 / 错误归一
|
||||
│ │ └── index.js
|
||||
│ ├── hooks/
|
||||
│ │ ├── useRequest.js # 查询:取消、重试、refetch
|
||||
│ │ ├── useMutation.js # 写操作:pending、错误、乐观更新
|
||||
│ │ └── useToast.js
|
||||
│ ├── components/
|
||||
│ │ ├── AsyncBoundary.jsx # 统一 loading / error / empty / retry
|
||||
│ │ ├── VideoGrid.jsx
|
||||
│ │ ├── EmptyState.jsx
|
||||
│ │ ├── ErrorState.jsx
|
||||
│ │ ├── Toast.jsx
|
||||
│ │ └── skeletons/index.jsx
|
||||
│ ├── lib/ # format / seo / media
|
||||
│ └── styles/ # tokens / base / layout / 组件样式
|
||||
├── entities/ # 领域模型与数据转换
|
||||
│ └── drama/
|
||||
│ ├── model.js # normalizeDrama / normalizeProgress
|
||||
│ └── api.js # 剧集、分集、播放、统计
|
||||
├── features/ # 业务域(页面 + 域内组件 + 域内 hook)
|
||||
│ ├── home/ HomePage.jsx / CategoryBar.jsx / useHomeFeed.js
|
||||
│ ├── shorts/ ShortsFeedPage.jsx / ShortSlide.jsx / useShortsFeed.js
|
||||
│ ├── watch/ WatchPage.jsx / EpisodeRail.jsx / useEpisodePlayback.js
|
||||
│ ├── search/ SearchPage.jsx / useSearchResults.js
|
||||
│ ├── history/ HistoryPage.jsx
|
||||
│ ├── listing/ ListingPage.jsx
|
||||
│ └── about/ AboutPage.jsx + i18n/about.js
|
||||
├── layouts/
|
||||
│ ├── AppShell.jsx # 只做骨架拼装
|
||||
│ └── components/
|
||||
│ ├── TopBar.jsx
|
||||
│ ├── SearchBox.jsx
|
||||
│ ├── CreateMenu.jsx
|
||||
│ ├── Sidebar.jsx
|
||||
│ └── BottomNav.jsx
|
||||
├── i18n/
|
||||
│ ├── index.js # 聚合 + getLocale
|
||||
│ └── locales/{id,en,zh}.js
|
||||
└── main.jsx
|
||||
```
|
||||
|
||||
依赖方向固定为:`features → entities → shared`,`layouts → shared`;禁止 `shared` 反向引用 `features`,禁止页面直接调用 `fetch`。
|
||||
|
||||
## 三、请求层设计(重点)
|
||||
|
||||
### 1. 统一错误类型
|
||||
|
||||
```js
|
||||
// shared/api/errors.js
|
||||
export class ApiError extends Error {
|
||||
constructor(message, { status = 0, code = null, path = '', cause } = {}) { ... }
|
||||
}
|
||||
export class TimeoutError extends ApiError {}
|
||||
export class NetworkError extends ApiError {}
|
||||
```
|
||||
|
||||
页面只判断 `error instanceof TimeoutError` / `error.status`,不再解析字符串。
|
||||
|
||||
### 2. 拦截器管线
|
||||
|
||||
```js
|
||||
// shared/api/client.js
|
||||
const client = createHttpClient({ baseURL, timeout: 12000 });
|
||||
|
||||
client.useRequest((config) => ({
|
||||
...config,
|
||||
headers: { 'X-Device-Id': getDeviceId(), 'X-Client-Platform': 'web', ...config.headers },
|
||||
}));
|
||||
|
||||
client.useResponse((response) => response); // 预留:解包、埋点
|
||||
client.useError((error) => { /* 归一化 + 401 处理入口 */ throw error; });
|
||||
|
||||
client.get('/app/home/feed');
|
||||
client.post('/app/likes/toggle', { targetType, targetId });
|
||||
```
|
||||
|
||||
- 请求拦截器:设备 ID、平台头、Content-Type、语言头(预留)。
|
||||
- 响应拦截器:`code === 0/200` 时解包 `data`;否则抛 `ApiError(code, message)`。
|
||||
- 错误拦截器:`AbortError` → 取消(静默);超时 → `TimeoutError`;`fetch` 异常 → `NetworkError`;HTTP 非 2xx → `ApiError(status)`。
|
||||
- 所有查询共用 `AbortSignal`,组件卸载即取消,避免切页后 setState。
|
||||
|
||||
### 3. 端点按域拆分
|
||||
|
||||
```text
|
||||
entities/drama/api.js getDrama / getEpisodes / getPlayback / getStats
|
||||
features/home/api.js getHomeFeed / getCategories / getCategoryDramas
|
||||
features/search/api.js search
|
||||
features/history/api.js getRecent / clearHistory / reportProgress
|
||||
features/shared? likes / favorites / comments → shared/api/engagement.js
|
||||
```
|
||||
|
||||
`src/services/appApi.js` 在迁移期保留为兼容门面,阶段 6 删除。
|
||||
|
||||
## 四、组件抽离清单
|
||||
|
||||
| 抽离项 | 来源 | 去向 |
|
||||
| --- | --- | --- |
|
||||
| `TopBar` / `SearchBox` / `CreateMenu` / `Sidebar` / `BottomNav` | `AppShell.jsx` | `layouts/components/` |
|
||||
| `ShortSlide` | `ShortsFeedPage.jsx` 内的私有组件 | `features/shorts/components/ShortSlide.jsx` |
|
||||
| `EpisodeRail` | `WatchPage.jsx` 右侧剧集列表 | `features/watch/components/EpisodeRail.jsx` |
|
||||
| `AsyncBoundary` | 4 个页面重复的三态 | `shared/components/AsyncBoundary.jsx` |
|
||||
| `VideoGrid` | `HomePage` / `ListingPage` / `SearchPage` 重复网格 | `shared/components/VideoGrid.jsx` |
|
||||
| `EmptyState` / `ErrorState` | 各页面 `api-note` / `search-empty` | `shared/components/` |
|
||||
| `Toast` | Shorts / Watch 各自实现 | `shared/components/Toast.jsx` + `useToast` |
|
||||
| 骨架屏 | `Skeletons.jsx` | `shared/components/skeletons/`(按页面一个文件 + index 聚合) |
|
||||
| 点赞/收藏交互 | Shorts 与 Watch 重复 | `shared/hooks/useEngagement.js` |
|
||||
|
||||
`AsyncBoundary` 使用方式:
|
||||
|
||||
```jsx
|
||||
<AsyncBoundary
|
||||
loading={request.loading}
|
||||
error={request.error}
|
||||
empty={!items.length}
|
||||
skeleton={<VideoGridSkeleton />}
|
||||
emptyText={locale.empty}
|
||||
onRetry={request.refetch}
|
||||
>
|
||||
<VideoGrid items={items} />
|
||||
</AsyncBoundary>
|
||||
```
|
||||
|
||||
## 五、分阶段实施
|
||||
|
||||
| 阶段 | 内容 | 验收 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 记录基线:`pnpm build` / `pnpm lint` 结果,页面清单截图 | 基线存档 |
|
||||
| P1 | `shared/api`:错误类型、client、拦截器、config;`appApi` 改为基于 client | build + lint,接口行为不变 |
|
||||
| P2 | `entities/drama`:模型归一化、分页、媒体 URL 收敛;页面改用 | 首页 / 列表 / 搜索数据一致 |
|
||||
| P3 | `useRequest` / `useMutation` / `useToast` / `useEngagement`;替换 `useApi` 与重复逻辑 | 点赞、收藏、评论、上传弹窗回归 |
|
||||
| P4 | 拆 `AppShell` 为 TopBar/SearchBox/CreateMenu/Sidebar/BottomNav;路由懒加载 | 侧栏收起、移动端抽屉、搜索跳转回归 |
|
||||
| P5 | 页面迁入 `features/`,抽 ShortSlide、EpisodeRail、VideoGrid、AsyncBoundary、骨架屏 | 首页 / Shorts / 播放 / 搜索 / 历史 / 关于全量回归 |
|
||||
| P6 | 样式分层(tokens/base/legacy 分离),删除旧兼容层与 `services/appApi.js` | 视觉 diff 无变化,build + lint 通过 |
|
||||
|
||||
每个阶段独立提交,提交信息遵循仓库现有 `feat:` / `refactor:` 风格。
|
||||
|
||||
## 六、风险与对策
|
||||
|
||||
1. **视觉回归**:P4/P5 只调整结构,CSS 类名与覆盖顺序保持不变;阶段结束对照截图。
|
||||
2. **Shorts 滑动性能**:`ShortsFeedPage` 的 IntersectionObserver、scroll-snap、键盘/滚轮逻辑整体迁移,不重写。
|
||||
3. **ID 精度**:所有 episode/drama ID 继续保持 `String()`,禁止 `Number()`;在 `entities/drama/model.js` 注释固化。
|
||||
4. **Context 拆分**:拆 locale 与 ui 后,确认切语言仍能刷新全站文案、搜索输入不再触发页面重渲染。
|
||||
5. **接口兼容**:P1/P2 期间 `appApi` 门面保留,页面可逐步迁移,避免一次性大爆炸。
|
||||
|
||||
## 七、验收标准
|
||||
|
||||
- `pnpm build` 通过,主包因路由懒加载下降(当前 907KB)。
|
||||
- `pnpm lint` 0 error。
|
||||
- 路由回归:`/`、`/shorts`、`/watch/:id`、`/search`、`/history`、`/subscriptions`、`/library`、`/about`。
|
||||
- 交互回归:上下滑切集、Shorts 上下滑、点赞/收藏/评论、语言切换、移动端侧栏、上传弹窗。
|
||||
- 目录符合本文第二节,跨层依赖检查无违规。
|
||||
@@ -0,0 +1,32 @@
|
||||
// Author: fruit.com.cn
|
||||
import js from '@eslint/js';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/**', 'node_modules/**'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: { ...globals.browser },
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
plugins: { react, 'react-hooks': reactHooks },
|
||||
settings: { react: { version: 'detect' } },
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['vite.config.js', 'eslint.config.js'],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
];
|
||||
|
Before Width: | Height: | Size: 72 KiB |
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
@@ -16,5 +17,12 @@
|
||||
"react-dom": "latest",
|
||||
"react-router-dom": "latest",
|
||||
"vite": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.14.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 570 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 605 KiB |
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 61 KiB |
@@ -1,13 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { StoreProvider } from './store';
|
||||
import { AppShell } from '../layouts/AppShell';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { ListingPage } from '../pages/ListingPage';
|
||||
import { SearchPage } from '../pages/SearchPage';
|
||||
import { WatchPage } from '../pages/WatchPage';
|
||||
import { ShortsFeedPage } from '../pages/ShortsFeedPage';
|
||||
import { HistoryPage } from '../pages/HistoryPage';
|
||||
import { AboutPage } from '../pages/AboutPage';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AppProviders } from './providers/AppProviders';
|
||||
import { AppRouter } from './router';
|
||||
|
||||
export function App() { return <BrowserRouter><StoreProvider><Routes><Route element={<AppShell />}><Route path="/" element={<HomePage />} /><Route path="/shorts" element={<ShortsFeedPage />} /><Route path="/subscriptions" element={<ListingPage type="subscriptions" />} /><Route path="/library" element={<ListingPage type="library" />} /><Route path="/history" element={<HistoryPage />} /><Route path="/about" element={<AboutPage />} /><Route path="/search" element={<SearchPage />} /><Route path="/watch/:id" element={<WatchPage />} /></Route></Routes></StoreProvider></BrowserRouter>; }
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppProviders>
|
||||
<AppRouter />
|
||||
</AppProviders>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Author: fruit.com.cn
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getLocale } from '../../i18n';
|
||||
import { detectLocaleFromIp } from '../../shared/lib/geo';
|
||||
import { applySeo } from '../../shared/lib/seo';
|
||||
|
||||
const LocaleContext = createContext(null);
|
||||
const UiContext = createContext(null);
|
||||
const LANG_STORAGE_KEY = 'ymb.lang';
|
||||
const SUPPORTED_LANGS = ['id', 'en', 'zh'];
|
||||
|
||||
function readUrlLang() {
|
||||
const value = new URLSearchParams(window.location.search).get('lang');
|
||||
return SUPPORTED_LANGS.includes(value) ? value : '';
|
||||
}
|
||||
|
||||
function readStoredLang() {
|
||||
try {
|
||||
const value = window.localStorage.getItem(LANG_STORAGE_KEY);
|
||||
return SUPPORTED_LANGS.includes(value) ? value : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readNavigatorLang() {
|
||||
const code = (navigator.language || '').toLowerCase();
|
||||
if (code.startsWith('id')) return 'id';
|
||||
if (code.startsWith('zh')) return 'zh';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function detectInitialLang() {
|
||||
return readUrlLang() || readStoredLang() || readNavigatorLang();
|
||||
}
|
||||
|
||||
export function AppProviders({ children }) {
|
||||
const [lang, setLangState] = useState(detectInitialLang);
|
||||
const [query, setQuery] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 760);
|
||||
const userSelectedRef = useRef(false);
|
||||
const locale = getLocale(lang);
|
||||
|
||||
// 首次进入且没有明确偏好时,按 IP 归属地自动匹配语言
|
||||
useEffect(() => {
|
||||
if (readUrlLang() || readStoredLang()) return undefined;
|
||||
let active = true;
|
||||
detectLocaleFromIp().then((detected) => {
|
||||
if (active && detected && !userSelectedRef.current) setLangState(detected);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setLang = useCallback((next) => {
|
||||
userSelectedRef.current = true;
|
||||
setLangState(next);
|
||||
try {
|
||||
window.localStorage.setItem(LANG_STORAGE_KEY, next);
|
||||
} catch {
|
||||
// 隐私模式下写入失败时仍保证本次会话生效
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applySeo(locale, window.location.pathname);
|
||||
}, [lang, locale]);
|
||||
|
||||
const localeValue = useMemo(() => ({ lang, locale, setLang }), [lang, locale, setLang]);
|
||||
const uiValue = useMemo(() => ({ query, setQuery, sidebarOpen, setSidebarOpen }), [query, sidebarOpen]);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={localeValue}>
|
||||
<UiContext.Provider value={uiValue}>{children}</UiContext.Provider>
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const value = useContext(LocaleContext);
|
||||
if (!value) throw new Error('useLocale must be used within AppProviders');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useUi() {
|
||||
const value = useContext(UiContext);
|
||||
if (!value) throw new Error('useUi must be used within AppProviders');
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Author: fruit.com.cn
|
||||
import { lazy } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { AppShell } from '../layouts/AppShell';
|
||||
|
||||
const HomePage = lazy(() => import('../features/home/HomePage').then((module) => ({ default: module.HomePage })));
|
||||
const ShortsFeedPage = lazy(() => import('../features/shorts/ShortsFeedPage').then((module) => ({ default: module.ShortsFeedPage })));
|
||||
const WatchPage = lazy(() => import('../features/watch/WatchPage').then((module) => ({ default: module.WatchPage })));
|
||||
const SearchPage = lazy(() => import('../features/search/SearchPage').then((module) => ({ default: module.SearchPage })));
|
||||
const HistoryPage = lazy(() => import('../features/history/HistoryPage').then((module) => ({ default: module.HistoryPage })));
|
||||
const ListingPage = lazy(() => import('../features/listing/ListingPage').then((module) => ({ default: module.ListingPage })));
|
||||
const AboutPage = lazy(() => import('../features/about/AboutPage').then((module) => ({ default: module.AboutPage })));
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/shorts" element={<ShortsFeedPage />} />
|
||||
<Route path="/subscriptions" element={<ListingPage type="subscriptions" />} />
|
||||
<Route path="/library" element={<ListingPage type="library" />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/watch/:id" element={<WatchPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
import { getLocale } from '../i18n';
|
||||
import { applySeo } from '../lib/seo';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const StoreContext = createContext(null);
|
||||
export function StoreProvider({ children }) { const initial = new URLSearchParams(location.search).get('lang'); const [lang, setLangState] = useState(initial || 'id'); const [query, setQuery] = useState(''); const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 760); const setLang = (next) => { setLangState(next); const url = new URL(location.href); url.searchParams.set('lang', next); history.replaceState(null, '', url); }; const locale = getLocale(lang); useEffect(() => applySeo(locale, location.pathname), [lang, locale]); const value = useMemo(() => ({ lang, locale, setLang, query, setQuery, sidebarOpen, setSidebarOpen }), [lang, locale, query, sidebarOpen]); return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>; }
|
||||
export function useAppStore() { return useContext(StoreContext); }
|
||||
@@ -1,13 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProgress }) {
|
||||
const ref = useRef(null); const [ready, setReady] = useState(false); const [error, setError] = useState(''); const playedRef = useRef(false); const onProgressRef = useRef(onProgress);
|
||||
useEffect(() => { onProgressRef.current = onProgress; }, [onProgress]);
|
||||
useEffect(() => { const video = ref.current; if (!video) return undefined; const flush = () => { if (playedRef.current) onProgressRef.current?.(video.currentTime, video.duration, true); }; video.addEventListener('pause', flush); window.addEventListener('pagehide', flush); return () => { flush(); video.removeEventListener('pause', flush); window.removeEventListener('pagehide', flush); }; }, []);
|
||||
useEffect(() => { const video = ref.current; if (!video || !src) return undefined; video.muted = muted; playedRef.current = false; setReady(false); setError(''); const hls = new Hls({ enableWorker: true }); const start = () => setReady(true); const onError = (_event, data) => { if (data?.fatal) { const message = `${data.details || 'Video stream error'} · ${data.response?.code || ''}`; console.error('YMB HLS ERROR', data); setError(message); } }; const onVideoError = () => { const message = `VIDEO_ERROR: ${video.error?.code || 'unknown'} ${video.error?.message || ''}`; console.error(message, { src, mediaError: video.error }); setError(message); }; hls.on(Hls.Events.MANIFEST_PARSED, start); hls.on(Hls.Events.ERROR, onError); video.addEventListener('error', onVideoError); if (Hls.isSupported()) { hls.attachMedia(video); hls.loadSource(src); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src; video.addEventListener('loadedmetadata', start, { once: true }); } else setError('This browser does not support HLS'); return () => { video.removeEventListener('error', onVideoError); hls.destroy(); video.pause(); }; }, [src, muted]);
|
||||
useEffect(() => { const video = ref.current; if (!video || !ready) return; if (autoPlay) video.play().catch((playError) => { if (playError.name !== 'AbortError') setError(`PLAY_FAILED: ${playError.message}`); }); else video.pause(); }, [autoPlay, ready]);
|
||||
const playNow = () => { const video = ref.current; if (video) { video.muted = false; video.play().catch((playError) => setError(playError.message)); } };
|
||||
return <div className="video-player-wrap"><video ref={ref} className="video-player" poster={poster} controls playsInline muted={muted} onTimeUpdate={(event) => { if (event.currentTarget.currentTime > 0) playedRef.current = true; onProgress?.(event.currentTarget.currentTime, event.currentTarget.duration, false); }} />{!ready && !error && <button className="video-play-button" onClick={playNow}>▶</button>}{error && <button className="video-error-button" onClick={playNow}>{error} · 点击重试</button>}</div>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Author: fruit.com.cn
|
||||
import { http } from '../../shared/api';
|
||||
import { toQuery } from '../../shared/api/query';
|
||||
|
||||
export const categoryApi = {
|
||||
list: (options) => http.get('/app/categories', options),
|
||||
dramas: (id, pageIndex = 1, pageSize = 20, options) => http.get(`/app/categories/${encodeURIComponent(id)}/dramas${toQuery({ pageIndex, pageSize })}`, options),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
// Author: fruit.com.cn
|
||||
import { http } from '../../shared/api';
|
||||
import { toQuery } from '../../shared/api/query';
|
||||
|
||||
export const dramaApi = {
|
||||
get: (id, options) => http.get(`/app/dramas/${encodeURIComponent(id)}`, options),
|
||||
playback: (episodeId, options) => http.get(`/app/episodes/${encodeURIComponent(episodeId)}/play`, options),
|
||||
stats: (episodeId, options) => http.get(`/app/episodes/${encodeURIComponent(episodeId)}/stats`, options),
|
||||
homeFeed: (options) => http.get('/app/home/feed', options),
|
||||
feed: ({ types = '', categoryId = '', cursor, size = 10 } = {}, options) => http.get(`/app/feed${toQuery({ size, types, categoryId, cursor })}`, options),
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// Author: fruit.com.cn
|
||||
import { getImage, resolveMedia } from '../../shared/lib/media';
|
||||
import { getText } from '../../shared/lib/text';
|
||||
|
||||
export function normalizeDrama(item = {}, lang = 'id') {
|
||||
const id = String(item.id ?? item.dramaId ?? item.code ?? '');
|
||||
const episodes = item.episodes || item.episodeList || (item.preview ? [item.preview] : []);
|
||||
const preview = item.preview || episodes[0] || null;
|
||||
return {
|
||||
id,
|
||||
title: getText(item.titleLocales || item.locales || item.title, lang) || item.name || 'YMB Drama',
|
||||
channel: getText(item.channelLocales || item.channel, lang) || item.creatorName || 'YMB Originals',
|
||||
genre: getText(item.categoryLocales || item.genre, lang) || getText(item.types?.[0], lang),
|
||||
image: getImage(item),
|
||||
duration: item.duration || preview?.duration || '',
|
||||
views: item.views || item.viewCount || '',
|
||||
likeCount: Number(item.likeCount) || 0,
|
||||
favoriteCount: Number(item.favoriteCount) || 0,
|
||||
commentCount: Number(item.commentCount) || 0,
|
||||
description: getText(item.synopsisLocales, lang) || '',
|
||||
episodes: episodes.map((episode) => ({ ...episode, playUrl: resolveMedia(episode.playUrl) })),
|
||||
preview: preview ? { ...preview, playUrl: resolveMedia(preview.playUrl) } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProgress(item = {}, lang = 'id') {
|
||||
const durationMs = Number(item.durationMs) || 0;
|
||||
const positionMs = Number(item.positionMs) || 0;
|
||||
return {
|
||||
id: String(item.dramaId ?? ''),
|
||||
episodeId: String(item.episodeId ?? ''),
|
||||
title: getText(item.titleLocales, lang) || item.dramaTitle || 'YMB Drama',
|
||||
image: resolveMedia(item.dramaCoverLandscape || item.dramaCover || ''),
|
||||
episodeNumber: item.episodeNumber == null ? null : Number(item.episodeNumber),
|
||||
totalEpisodes: item.totalEpisodes == null ? null : Number(item.totalEpisodes),
|
||||
positionMs,
|
||||
durationMs,
|
||||
fraction: durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0,
|
||||
viewCount: item.viewCount,
|
||||
finished: Boolean(item.finished),
|
||||
updatedAt: item.updatedAt || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function uniqueDramas(items) {
|
||||
const seen = new Set();
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Author: fruit.com.cn
|
||||
import { ArrowRight, Clapperboard, Cpu, Play, Smartphone, Sparkles, Users } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { aboutContent } from '../i18n/about';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
import { aboutContent } from './content';
|
||||
|
||||
const APP_DOWNLOAD_URL = 'https://cdnetworksdist.okrivo.xyz/wPwBG';
|
||||
|
||||
@@ -13,7 +13,7 @@ const PILLARS = [
|
||||
];
|
||||
|
||||
export function AboutPage() {
|
||||
const { locale } = useAppStore();
|
||||
const { locale } = useLocale();
|
||||
const copy = aboutContent[locale.code] || aboutContent.en;
|
||||
|
||||
return (
|
||||
@@ -1,10 +1,12 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { appApi } from '../services/appApi';
|
||||
import { normalizePage, normalizeProgress } from '../services/normalizers';
|
||||
import { HistoryGridSkeleton } from '../components/Skeletons';
|
||||
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 '';
|
||||
@@ -12,30 +14,22 @@ function episodeLabel(record, localeCode) {
|
||||
}
|
||||
|
||||
export function HistoryPage() {
|
||||
const { locale } = useAppStore();
|
||||
const [records, setRecords] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('');
|
||||
try {
|
||||
const payload = await appApi.watchRecent(1, 20);
|
||||
setRecords(normalizePage(payload).list.map((item) => normalizeProgress(item, locale.code)));
|
||||
} catch (loadError) {
|
||||
setError(loadError.message || 'Request failed');
|
||||
setRecords([]);
|
||||
}
|
||||
}, [locale.code]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
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 appApi.clearWatchHistory();
|
||||
setRecords([]);
|
||||
} catch (clearError) {
|
||||
setError(clearError.message || 'Request failed');
|
||||
await clearHistory.run();
|
||||
history.refetch();
|
||||
} catch {
|
||||
// 错误由 clearHistory.error 呈现
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,9 +39,9 @@ export function HistoryPage() {
|
||||
<h1>{locale.history}</h1>
|
||||
{!!records?.length && <button type="button" onClick={clearAll}>{locale.clearHistory}</button>}
|
||||
</div>
|
||||
{records === null && <HistoryGridSkeleton count={6} label={locale.loading} />}
|
||||
{error && <div className="api-note warning">{error}</div>}
|
||||
{records && !records.length && !error && <div className="api-note">{locale.historyEmpty}</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) => {
|
||||
@@ -1,13 +1,18 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useMemo, useState } from 'react';
|
||||
import { MediaCard } from '../components/MediaCard';
|
||||
import { CategoryChipsSkeleton, ShortsShelfSkeleton, VideoGridSkeleton } from '../components/Skeletons';
|
||||
import { videos } from '../data/catalog';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { searchCatalog } from '../services/catalogService';
|
||||
import { appApi } from '../services/appApi';
|
||||
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
|
||||
import { useApi } from '../hooks/useApi';
|
||||
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;
|
||||
@@ -42,24 +47,26 @@ function fallbackGenreIndex(categoryId) {
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { locale } = useAppStore();
|
||||
const { locale } = useLocale();
|
||||
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_ID);
|
||||
const homeApi = useApi(
|
||||
() => appApi.homeFeed().then((payload) => normalizeHomeItems(payload, locale.code)),
|
||||
const isAllCategory = activeCategory === ALL_CATEGORY_ID;
|
||||
|
||||
const feedRequest = useRequest(
|
||||
({ signal }) => dramaApi.homeFeed({ signal }).then((payload) => normalizeHomeItems(payload, locale.code)),
|
||||
[locale.code],
|
||||
);
|
||||
const categoriesApi = useApi(
|
||||
() => appApi.categories().then((payload) => normalizeCategories(payload, locale.code)),
|
||||
const categoriesRequest = useRequest(
|
||||
({ signal }) => categoryApi.list({ signal }).then((payload) => normalizeCategories(payload, locale.code)),
|
||||
[locale.code],
|
||||
);
|
||||
const categoryApi = useApi(
|
||||
() => (activeCategory === ALL_CATEGORY_ID
|
||||
? Promise.resolve(null)
|
||||
: appApi.categoryDramas(activeCategory).then((payload) => normalizeCategoryItems(payload, 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 = homeApi.data || [];
|
||||
const serverCategories = categoriesApi.data || [];
|
||||
|
||||
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],
|
||||
@@ -67,16 +74,17 @@ export function HomePage() {
|
||||
const categories = useMemo(() => {
|
||||
const allCategory = { id: ALL_CATEGORY_ID, label: locale.genres[0] };
|
||||
if (serverCategories.length) return [allCategory, ...serverCategories];
|
||||
return categoriesApi.loading ? [allCategory] : [allCategory, ...fallbackCategories];
|
||||
}, [categoriesApi.loading, fallbackCategories, locale.genres, 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 isAllCategory = activeCategory === ALL_CATEGORY_ID;
|
||||
const loading = isAllCategory ? homeApi.loading : categoryApi.loading;
|
||||
const error = isAllCategory ? homeApi.error : categoryApi.error;
|
||||
const remoteItems = isAllCategory ? homeItems : (categoryApi.data || []);
|
||||
|
||||
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);
|
||||
@@ -84,30 +92,19 @@ export function HomePage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{categoriesApi.loading && !serverCategories.length ? <CategoryChipsSkeleton /> : (
|
||||
<div className="youtube-category-bar" role="tablist" aria-label="Categories">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
className={`youtube-category-chip ${activeCategory === category.id ? 'active' : ''}`}
|
||||
key={category.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeCategory === category.id}
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{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>}
|
||||
{!loading && !error && !items.length && <div className="api-note">{locale.empty}</div>}
|
||||
{loading && !items.length ? <VideoGridSkeleton count={FIRST_SHELF_LIMIT} label={locale.loading} /> : (
|
||||
<div className="video-grid" id="recommended-title">
|
||||
{firstShelf.map((video) => <MediaCard key={video.id} video={video} />)}
|
||||
</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">
|
||||
@@ -115,18 +112,18 @@ export function HomePage() {
|
||||
<h2 id="shorts-title"><span className="shorts-mark">▶</span>{locale.shortTitle}</h2>
|
||||
<button type="button">{locale.explore}</button>
|
||||
</div>
|
||||
{homeApi.loading && !homeItems.length ? <ShortsShelfSkeleton count={SHORTS_LIMIT} label={locale.loading} /> : (
|
||||
{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}>
|
||||
<div className="video-grid">
|
||||
{secondShelf.map((video) => <MediaCard key={video.id} video={video} />)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -1,92 +1,22 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { appApi } from '../services/appApi';
|
||||
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
|
||||
import { VideoPlayer } from '../components/player/VideoPlayer';
|
||||
import { CommentsSheet } from '../components/CommentsSheet';
|
||||
import { ShortsSkeleton } from '../components/Skeletons';
|
||||
import { formatCount } from '../lib/format';
|
||||
import { reportWatchProgress } from '../services/watchProgress';
|
||||
import { useFollowedChannels } from '../hooks/useFollowedChannels';
|
||||
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;
|
||||
|
||||
function ShortSlide({ video, active, locale, followed, onToggleFollow, onOpenComments, onShare }) {
|
||||
const src = video.preview?.playUrl;
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(video.likeCount);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [saveCount, setSaveCount] = useState(video.favoriteCount);
|
||||
|
||||
const toggleLike = async () => {
|
||||
try {
|
||||
const result = await appApi.toggleLike(video.preview?.id ? 'EPISODE' : 'DRAMA', video.preview?.id || video.id);
|
||||
setLiked(Boolean(result?.liked));
|
||||
setLikeCount(Number(result?.count) || 0);
|
||||
} catch (error) {
|
||||
console.error('YMB LIKE FAILED', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSave = async () => {
|
||||
try {
|
||||
const next = await appApi.toggleFavorite(video.id);
|
||||
setSaved(Boolean(next));
|
||||
setSaveCount((count) => Math.max(0, count + (next ? 1 : -1)));
|
||||
} catch (error) {
|
||||
console.error('YMB SAVE FAILED', error);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShortsFeedPage() {
|
||||
const { locale, sidebarOpen } = useAppStore();
|
||||
const { locale } = useLocale();
|
||||
const { sidebarOpen } = useUi();
|
||||
const [params] = useSearchParams();
|
||||
const startId = params.get('start');
|
||||
const [items, setItems] = useState([]);
|
||||
@@ -94,12 +24,11 @@ export function ShortsFeedPage() {
|
||||
const [status, setStatus] = useState({ loading: true, error: '', hasMore: true });
|
||||
const { channels: followedChannels, toggle: toggleFollow } = useFollowedChannels();
|
||||
const [commentsVideo, setCommentsVideo] = useState(null);
|
||||
const [toast, setToast] = useState('');
|
||||
const toast = useToast();
|
||||
const feedRef = useRef(null);
|
||||
const cursorRef = useRef(null);
|
||||
const loadingRef = useRef(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const toastTimer = useRef(null);
|
||||
|
||||
const loadPage = useCallback(async (reset) => {
|
||||
if (loadingRef.current && !reset) return;
|
||||
@@ -108,7 +37,7 @@ export function ShortsFeedPage() {
|
||||
loadingRef.current = true;
|
||||
setStatus((prev) => ({ ...prev, loading: true, error: '' }));
|
||||
try {
|
||||
const payload = await appApi.feed({ cursor: reset ? null : cursorRef.current, size: PAGE_SIZE });
|
||||
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;
|
||||
@@ -158,10 +87,6 @@ export function ShortsFeedPage() {
|
||||
if (index >= items.length - PRELOAD_THRESHOLD) loadPage(false);
|
||||
}, [activeId, items, loadPage, status.hasMore, status.loading]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (toastTimer.current) window.clearTimeout(toastTimer.current);
|
||||
}, []);
|
||||
|
||||
const activeIndex = Math.max(0, items.findIndex((item) => item.id === activeId));
|
||||
const move = (delta) => {
|
||||
const node = feedRef.current;
|
||||
@@ -170,12 +95,6 @@ export function ShortsFeedPage() {
|
||||
if (next !== activeIndex) node.scrollTo({ top: next * node.clientHeight, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const showToast = (message) => {
|
||||
setToast(message);
|
||||
if (toastTimer.current) window.clearTimeout(toastTimer.current);
|
||||
toastTimer.current = window.setTimeout(() => setToast(''), 2000);
|
||||
};
|
||||
|
||||
const shareVideo = async (video) => {
|
||||
const url = `${window.location.origin}/watch/${video.id}`;
|
||||
if (navigator.share) {
|
||||
@@ -188,9 +107,9 @@ export function ShortsFeedPage() {
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast(locale.linkCopied);
|
||||
toast.show(locale.linkCopied);
|
||||
} catch {
|
||||
showToast(url);
|
||||
toast.show(url);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -201,6 +120,7 @@ export function ShortsFeedPage() {
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeIndex, items.length]);
|
||||
|
||||
return (
|
||||
@@ -215,7 +135,6 @@ export function ShortsFeedPage() {
|
||||
key={video.id}
|
||||
video={video}
|
||||
active={video.id === activeId}
|
||||
locale={locale}
|
||||
followed={followedChannels.includes(video.channel)}
|
||||
onToggleFollow={() => toggleFollow(video.channel)}
|
||||
onOpenComments={() => setCommentsVideo(video)}
|
||||
@@ -231,7 +150,7 @@ export function ShortsFeedPage() {
|
||||
)}
|
||||
</section>
|
||||
{commentsVideo && <CommentsSheet video={commentsVideo} locale={locale} onClose={() => setCommentsVideo(null)} />}
|
||||
{toast && <div className="shorts-toast">{toast}</div>}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useApi(loader, deps = []) {
|
||||
const [state, setState] = useState({ data: null, loading: true, error: '' });
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState({ data: null, loading: true, error: '' });
|
||||
loader().then((data) => active && setState({ data, loading: false, error: '' })).catch((error) => active && setState({ data: null, loading: false, error: error.message || 'Request failed' }));
|
||||
return () => { active = false; };
|
||||
}, deps);
|
||||
return state;
|
||||
}
|
||||
@@ -1,133 +1,33 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Bell, Compass, History, Home, Info, ListVideo, Menu, Plus, Search, Upload, UserRound, X } from 'lucide-react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { LanguageSwitcher } from '../components/LanguageSwitcher';
|
||||
import { UploadVideoDialog } from '../components/UploadVideoDialog';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useLocale, useUi } from '../app/providers/AppProviders';
|
||||
import { BottomNav } from './components/BottomNav';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { TopBar } from './components/TopBar';
|
||||
import { UploadVideoDialog } from './components/UploadVideoDialog';
|
||||
|
||||
export function AppShell() {
|
||||
const { locale, query, setQuery, sidebarOpen, setSidebarOpen } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
const { locale } = useLocale();
|
||||
const { sidebarOpen, setSidebarOpen } = useUi();
|
||||
const location = useLocation();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const searchInputRef = useRef(null);
|
||||
const createRef = useRef(null);
|
||||
const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [ListVideo, locale.subscriptions, '/subscriptions']];
|
||||
const libraryItems = [[History, locale.history, '/history']];
|
||||
const aboutItems = [[Info, locale.about, '/about']];
|
||||
const immersive = location.pathname === '/shorts';
|
||||
// 移动端为抽屉式侧边栏,点击任一菜单项后必须收起
|
||||
const closeSidebarOnNavigate = (event) => {
|
||||
if (!event.target.closest?.('a')) return;
|
||||
if (window.matchMedia('(max-width: 760px)').matches) setSidebarOpen(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!createOpen) return undefined;
|
||||
const closeMenu = (event) => {
|
||||
if (!createRef.current?.contains(event.target)) setCreateOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', closeMenu);
|
||||
return () => document.removeEventListener('mousedown', closeMenu);
|
||||
}, [createOpen]);
|
||||
const submitSearch = (event) => {
|
||||
event.preventDefault();
|
||||
setSearchOpen(false);
|
||||
const keyword = query.trim();
|
||||
navigate(keyword ? `/search?q=${encodeURIComponent(keyword)}` : '/search');
|
||||
};
|
||||
const navClass = (to) => {
|
||||
const [path, search = ''] = to.split('?');
|
||||
if (location.pathname !== path) return '';
|
||||
const current = new URLSearchParams(location.search);
|
||||
return [...new URLSearchParams(search)].every(([key, value]) => current.get(key) === value) ? 'active' : '';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button className="icon-button topbar-menu" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button">
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<NavLink className="brand" to="/" aria-label="YMB">
|
||||
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="topbar-center">
|
||||
<form className={`search-form ${searchOpen ? 'open' : ''}`} onSubmit={submitSearch}>
|
||||
<span className="search-field">
|
||||
<input ref={searchInputRef} value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
|
||||
{query && (
|
||||
<button className="search-clear" type="button" aria-label={locale.clearSearch} onClick={() => { setQuery(''); searchInputRef.current?.focus(); }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<button aria-label="Search" type="submit"><Search size={20} /></button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="top-actions">
|
||||
<button className="icon-button mobile-search" onClick={() => setSearchOpen(!searchOpen)} aria-expanded={searchOpen} aria-label={locale.search} type="button"><Search size={22} /></button>
|
||||
<LanguageSwitcher />
|
||||
<div className="create-wrap" ref={createRef}>
|
||||
<button className="create-button" type="button" aria-haspopup="menu" aria-expanded={createOpen} onClick={() => setCreateOpen((open) => !open)}>
|
||||
<Plus size={20} /><span>{locale.create}</span>
|
||||
</button>
|
||||
{createOpen && (
|
||||
<div className="create-menu" role="menu">
|
||||
<button type="button" role="menuitem" onClick={() => { setCreateOpen(false); setUploadOpen(true); }}>
|
||||
<Upload size={18} /><span>{locale.uploadVideo}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={20} /></button>
|
||||
<span className="profile-chip" aria-label={locale.account}>N</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`} onClick={closeSidebarOnNavigate}>
|
||||
<nav className="sidebar-group">
|
||||
{primaryItems.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} className={() => navClass(to)} key={to}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="nav-rule" />
|
||||
<div className="sidebar-group">
|
||||
<NavLink to="/library" className={() => navClass('/library')}>
|
||||
<UserRound size={19} />
|
||||
<span className="sidebar-label">{locale.you}</span>
|
||||
</NavLink>
|
||||
{libraryItems.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} className={() => navClass(to)} key={label}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-rule" />
|
||||
<div className="sidebar-group">
|
||||
{aboutItems.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} className={() => navClass(to)} key={label}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}><Outlet />{!immersive && <footer className="site-footer">© {new Date().getFullYear()} {locale.copyright}</footer>}</main>
|
||||
<nav className="bottom-nav">
|
||||
{primaryItems.map(([Icon, label, to]) => <NavLink to={to} className={() => navClass(to)} key={to}><Icon size={24} /><span>{label}</span></NavLink>)}
|
||||
<NavLink to="/library" className={() => navClass('/library')}><UserRound size={24} /><span>{locale.you}</span></NavLink>
|
||||
</nav>
|
||||
{sidebarOpen && <button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button"><X /></button>}
|
||||
<TopBar onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} onUpload={() => setUploadOpen(true)} />
|
||||
<Sidebar />
|
||||
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}>
|
||||
<Suspense fallback={<div className="route-loading" aria-busy="true" />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
{!immersive && <footer className="site-footer">© {new Date().getFullYear()} {locale.copyright}</footer>}
|
||||
</main>
|
||||
<BottomNav />
|
||||
{sidebarOpen && (
|
||||
<button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button" />
|
||||
)}
|
||||
<UploadVideoDialog open={uploadOpen} onClose={() => setUploadOpen(false)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Compass, Home, ListVideo, UserRound } from 'lucide-react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
|
||||
export function BottomNav() {
|
||||
const { locale } = useLocale();
|
||||
const location = useLocation();
|
||||
const items = [
|
||||
[Home, locale.home, '/'],
|
||||
[Compass, locale.shorts, '/shorts'],
|
||||
[ListVideo, locale.subscriptions, '/subscriptions'],
|
||||
[UserRound, locale.you, '/library'],
|
||||
];
|
||||
const navClass = (to) => (location.pathname === to ? 'active' : '');
|
||||
|
||||
return (
|
||||
<nav className="bottom-nav">
|
||||
{items.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} className={() => navClass(to)} key={to}>
|
||||
<Icon size={24} /><span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Plus, Upload } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
|
||||
export function CreateMenu({ onUpload }) {
|
||||
const { locale } = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const closeMenu = (event) => {
|
||||
if (!menuRef.current?.contains(event.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', closeMenu);
|
||||
return () => document.removeEventListener('mousedown', closeMenu);
|
||||
}, [open]);
|
||||
|
||||
const selectUpload = () => {
|
||||
setOpen(false);
|
||||
onUpload?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-wrap" ref={menuRef}>
|
||||
<button className="create-button" type="button" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
|
||||
<Plus size={20} /><span>{locale.create}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="create-menu" role="menu">
|
||||
<button type="button" role="menuitem" onClick={selectUpload}>
|
||||
<Upload size={18} /><span>{locale.uploadVideo}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
import { LanguageFlag } from './LanguageFlag';
|
||||
|
||||
const languages = [
|
||||
@@ -12,7 +12,7 @@ const languages = [
|
||||
const titles = { id: 'Bahasa', en: 'Language', zh: '语言' };
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { lang, setLang } = useAppStore();
|
||||
const { lang, setLang } = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
const current = languages.find((item) => item.code === lang) || languages[0];
|
||||
const title = titles[lang] || 'Language';
|
||||
@@ -0,0 +1,47 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocale, useUi } from '../../app/providers/AppProviders';
|
||||
|
||||
export function SearchBox({ open = false, onSubmitted }) {
|
||||
const { locale } = useLocale();
|
||||
const { query, setQuery } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
const keyword = query.trim();
|
||||
onSubmitted?.();
|
||||
navigate(keyword ? `/search?q=${encodeURIComponent(keyword)}` : '/search');
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={`search-form ${open ? 'open' : ''}`} onSubmit={submit}>
|
||||
<span className="search-field">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={locale.search}
|
||||
aria-label={locale.search}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
className="search-clear"
|
||||
type="button"
|
||||
aria-label={locale.clearSearch}
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<button aria-label="Search" type="submit"><Search size={20} /></button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Compass, History, Home, Info, ListVideo, UserRound } from 'lucide-react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useLocale, useUi } from '../../app/providers/AppProviders';
|
||||
|
||||
export function Sidebar() {
|
||||
const { locale } = useLocale();
|
||||
const { sidebarOpen, setSidebarOpen } = useUi();
|
||||
const location = useLocation();
|
||||
|
||||
const primaryItems = [
|
||||
[Home, locale.home, '/'],
|
||||
[Compass, locale.shorts, '/shorts'],
|
||||
[ListVideo, locale.subscriptions, '/subscriptions'],
|
||||
];
|
||||
|
||||
const navClass = (to) => {
|
||||
if (location.pathname !== to) return '';
|
||||
return 'active';
|
||||
};
|
||||
|
||||
// 移动端为抽屉式侧边栏,点击任一菜单项后必须收起
|
||||
const closeOnNavigate = (event) => {
|
||||
if (!event.target.closest?.('a')) return;
|
||||
if (window.matchMedia('(max-width: 760px)').matches) setSidebarOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`} onClick={closeOnNavigate}>
|
||||
<nav className="sidebar-group">
|
||||
{primaryItems.map(([Icon, label, to]) => (
|
||||
<NavLink to={to} className={() => navClass(to)} key={to}>
|
||||
<Icon size={19} />
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="nav-rule" />
|
||||
<div className="sidebar-group">
|
||||
<NavLink to="/library" className={() => navClass('/library')}>
|
||||
<UserRound size={19} />
|
||||
<span className="sidebar-label">{locale.you}</span>
|
||||
</NavLink>
|
||||
<NavLink to="/history" className={() => navClass('/history')}>
|
||||
<History size={19} />
|
||||
<span className="sidebar-label">{locale.history}</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="nav-rule" />
|
||||
<div className="sidebar-group">
|
||||
<NavLink to="/about" className={() => navClass('/about')}>
|
||||
<Info size={19} />
|
||||
<span className="sidebar-label">{locale.about}</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Bell, Menu, Search } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
import { CreateMenu } from './CreateMenu';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { SearchBox } from './SearchBox';
|
||||
|
||||
export function TopBar({ onToggleSidebar, onUpload }) {
|
||||
const { locale } = useLocale();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button className="icon-button topbar-menu" onClick={onToggleSidebar} aria-label="Menu" type="button">
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<NavLink className="brand" to="/" aria-label="YMB">
|
||||
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="topbar-center">
|
||||
<SearchBox open={searchOpen} onSubmitted={() => setSearchOpen(false)} />
|
||||
</div>
|
||||
<div className="top-actions">
|
||||
<button className="icon-button mobile-search" onClick={() => setSearchOpen((open) => !open)} aria-expanded={searchOpen} aria-label={locale.search} type="button">
|
||||
<Search size={22} />
|
||||
</button>
|
||||
<LanguageSwitcher />
|
||||
<CreateMenu onUpload={onUpload} />
|
||||
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={20} /></button>
|
||||
<span className="profile-chip" aria-label={locale.account}>N</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
import { CheckCircle2, UploadCloud, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
@@ -10,7 +10,7 @@ function formatSize(bytes) {
|
||||
}
|
||||
|
||||
export function UploadVideoDialog({ open, onClose }) {
|
||||
const { locale } = useAppStore();
|
||||
const { locale } = useLocale();
|
||||
const [file, setFile] = useState(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -1,8 +1,8 @@
|
||||
// Author: fruit.com.cn
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import './styles.css';
|
||||
import './enterprise.css';
|
||||
import './youtube-overrides.css';
|
||||
import './shared/styles/base.css';
|
||||
import './shared/styles/enterprise.css';
|
||||
import './shared/styles/youtube-overrides.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
@@ -1,27 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
import { MediaCard } from '../components/MediaCard';
|
||||
import { SectionHeading } from '../components/SectionHeading';
|
||||
import { VideoGridSkeleton } from '../components/Skeletons';
|
||||
import { videos } from '../data/catalog';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { appApi } from '../services/appApi';
|
||||
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
|
||||
import { useApi } from '../hooks/useApi';
|
||||
|
||||
export function ListingPage({ type }) {
|
||||
const { locale } = useAppStore();
|
||||
const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library;
|
||||
const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]);
|
||||
const items = api.data?.length ? api.data : videos;
|
||||
return (
|
||||
<>
|
||||
<SectionHeading title={title} />
|
||||
{api.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
|
||||
<>
|
||||
{api.error && <div className="api-note warning">{locale.apiFallback}</div>}
|
||||
<div className="video-grid">{items.map((video) => <MediaCard key={video.id} video={video} />)}</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { MediaCard } from '../components/MediaCard';
|
||||
import { SectionHeading } from '../components/SectionHeading';
|
||||
import { VideoGridSkeleton } from '../components/Skeletons';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { appApi } from '../services/appApi';
|
||||
import { normalizeDrama, normalizePage, uniqueDramas } from '../services/normalizers';
|
||||
import { useApi } from '../hooks/useApi';
|
||||
|
||||
async function loadSearchResults(keyword, localeCode) {
|
||||
if (keyword) {
|
||||
const payload = await appApi.search(keyword);
|
||||
return normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode));
|
||||
}
|
||||
const payload = await appApi.homeFeed();
|
||||
const items = payload?.sections?.flatMap((section) => section.dramas || []) || [];
|
||||
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
|
||||
}
|
||||
|
||||
export function SearchPage() {
|
||||
const { locale } = useAppStore();
|
||||
const [params] = useSearchParams();
|
||||
const query = (params.get('q') || '').trim();
|
||||
const api = useApi(() => loadSearchResults(query, locale.code), [query, locale.code]);
|
||||
const results = api.data || [];
|
||||
return (
|
||||
<>
|
||||
<SectionHeading title={query ? `${locale.searchResults} “${query}”` : locale.picks} />
|
||||
{api.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
|
||||
<>
|
||||
{api.error && <div className="api-note warning">{locale.apiFallback}</div>}
|
||||
{!api.error && query && results.length === 0 ? (
|
||||
<div className="search-empty">
|
||||
<SearchIcon size={28} />
|
||||
<b>{locale.noResults} “{query}”</b>
|
||||
</div>
|
||||
) : (
|
||||
<div className="video-grid">{results.map((video) => <MediaCard key={video.id} video={video} />)}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Bookmark, Lock, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { appApi } from '../services/appApi';
|
||||
import { normalizeDrama, selectPlaybackUrl } from '../services/normalizers';
|
||||
import { useApi } from '../hooks/useApi';
|
||||
import { useFollowedChannels } from '../hooks/useFollowedChannels';
|
||||
import { VideoPlayer } from '../components/player/VideoPlayer';
|
||||
import { CommentsSheet } from '../components/CommentsSheet';
|
||||
import { WatchPageSkeleton } from '../components/Skeletons';
|
||||
import { formatCount, formatViews } from '../lib/format';
|
||||
import { reportWatchProgress } from '../services/watchProgress';
|
||||
|
||||
function episodeLabel(episode, index, code) {
|
||||
const number = episode?.number ?? index + 1;
|
||||
return code === 'zh' ? `第${number}集` : `Episode ${number}`;
|
||||
}
|
||||
|
||||
function WatchContent({ video, locale }) {
|
||||
const episodes = video.episodes?.length
|
||||
? (video.preview && !video.episodes.some((item) => item.id === video.preview.id) ? [video.preview, ...video.episodes] : video.episodes)
|
||||
: (video.preview ? [video.preview] : []);
|
||||
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 playback = useApi(() => currentEpisode && !currentEpisode.locked ? appApi.episodePlay(currentEpisode.id) : Promise.resolve(null), [currentEpisode?.id, currentEpisode?.locked]);
|
||||
const stats = useApi(() => currentEpisode?.id ? appApi.episodeStats(currentEpisode.id) : Promise.resolve(null), [currentEpisode?.id]);
|
||||
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 { channels, toggle } = useFollowedChannels();
|
||||
const followed = channels.includes(video.channel);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(Number(currentEpisode?.likeCount) || 0);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [saveCount, setSaveCount] = useState(Number(video.favoriteCount) || 0);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
const toastTimer = useRef(null);
|
||||
const swipeStart = useRef(null);
|
||||
const playerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const data = stats.data;
|
||||
if (!data) return;
|
||||
setLiked(Boolean(data.liked));
|
||||
setLikeCount(Number(data.likeCount) || 0);
|
||||
setSaved(Boolean(data.favorited));
|
||||
setSaveCount(Number(data.favoriteCount) || 0);
|
||||
}, [stats.data]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (toastTimer.current) window.clearTimeout(toastTimer.current);
|
||||
}, []);
|
||||
|
||||
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 node = playerRef.current;
|
||||
if (!node) return undefined;
|
||||
let last = 0;
|
||||
const onWheel = (event) => {
|
||||
if (Math.abs(event.deltaY) < 20) return;
|
||||
const forward = event.deltaY > 0;
|
||||
if (forward ? !nextEpisode : !previousEpisode) return;
|
||||
event.preventDefault();
|
||||
const now = Date.now();
|
||||
if (now - last < 700) return;
|
||||
last = now;
|
||||
if (forward) goNext(); else goPrevious();
|
||||
};
|
||||
node.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => node.removeEventListener('wheel', onWheel);
|
||||
}, [nextEpisode, previousEpisode]);
|
||||
|
||||
if (!currentEpisode) return <div className="api-note warning">{locale.playbackUnavailable}</div>;
|
||||
const commentCount = Number(stats.data?.commentCount ?? currentEpisode.commentCount) || 0;
|
||||
|
||||
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 showToast = (message) => {
|
||||
setToast(message);
|
||||
if (toastTimer.current) window.clearTimeout(toastTimer.current);
|
||||
toastTimer.current = window.setTimeout(() => setToast(''), 2000);
|
||||
};
|
||||
|
||||
const toggleLike = async () => {
|
||||
try {
|
||||
const result = await appApi.toggleLike('EPISODE', currentEpisode.id);
|
||||
setLiked(Boolean(result?.liked));
|
||||
setLikeCount(Number(result?.count) || 0);
|
||||
} catch (error) {
|
||||
console.error('YMB LIKE FAILED', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSave = async () => {
|
||||
try {
|
||||
const next = await appApi.toggleFavorite(video.id);
|
||||
setSaved(Boolean(next));
|
||||
setSaveCount((count) => Math.max(0, count + (next ? 1 : -1)));
|
||||
} catch (error) {
|
||||
console.error('YMB SAVE FAILED', error);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
showToast(locale.linkCopied);
|
||||
} catch {
|
||||
showToast(url);
|
||||
}
|
||||
};
|
||||
|
||||
const label = episodeLabel(currentEpisode, episodeIndex, locale.code);
|
||||
const commentVideo = { ...video, preview: currentEpisode };
|
||||
|
||||
return (
|
||||
<div className="watch-page">
|
||||
<div className="watch-primary">
|
||||
<div ref={playerRef} 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 || 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>
|
||||
<h1 className="watch-title">{video.title}</h1>
|
||||
<p className="watch-episode">{label}</p>
|
||||
<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={liked ? 'active' : ''} onClick={toggleLike} type="button"><ThumbsUp size={18} fill={liked ? 'currentColor' : 'none'} /><span>{formatCount(likeCount, locale.code)}</span></button>
|
||||
<button className={saved ? 'active' : ''} onClick={toggleSave} type="button"><Bookmark size={18} fill={saved ? 'currentColor' : 'none'} /><span>{formatCount(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>
|
||||
<div className="watch-description">
|
||||
<b>{views}{tag ? ` · ${tag}` : ''}</b>
|
||||
{video.description && <p>{video.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{episodes.length > 0 && (
|
||||
<aside className="watch-side">
|
||||
<h2>{locale.episodes}</h2>
|
||||
<div className="watch-episode-list">
|
||||
{episodes.map((episode, index) => (
|
||||
<button
|
||||
className={`watch-episode-item ${episode.id === currentEpisode.id ? 'active' : ''}`}
|
||||
key={episode.id}
|
||||
onClick={() => changeEpisode(episode, true)}
|
||||
type="button"
|
||||
>
|
||||
<span className="watch-episode-thumb">
|
||||
<img src={video.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>
|
||||
)}
|
||||
|
||||
{commentsOpen && <CommentsSheet video={commentVideo} locale={locale} onClose={() => setCommentsOpen(false)} />}
|
||||
{toast && <div className="shorts-toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WatchPage() {
|
||||
const { id } = useParams();
|
||||
const { locale } = useAppStore();
|
||||
const current = useApi(() => appApi.drama(id).then((data) => normalizeDrama(data, locale.code)), [id, locale.code]);
|
||||
const video = current.data;
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0 });
|
||||
const timer = window.setTimeout(() => window.scrollTo({ top: 0 }), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [id]);
|
||||
|
||||
if (!video && current.loading) return <WatchPageSkeleton label={locale.loading} />;
|
||||
if (!video) return <div className="api-note warning">{current.error || locale.empty}</div>;
|
||||
|
||||
return <WatchContent key={video.id} video={video} locale={locale} />;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
import { request } from './httpClient';
|
||||
|
||||
export const appApi = {
|
||||
homeFeed: () => request('/app/home/feed'),
|
||||
feed: ({ types = '', categoryId = '', cursor, size = 10 } = {}) => {
|
||||
const params = new URLSearchParams({ size: String(size) });
|
||||
if (types) params.set('types', types);
|
||||
if (categoryId) params.set('categoryId', String(categoryId));
|
||||
if (cursor !== undefined && cursor !== null && cursor !== '') params.set('cursor', String(cursor));
|
||||
return request(`/app/feed?${params.toString()}`);
|
||||
},
|
||||
categories: () => request('/app/categories'),
|
||||
categoryDramas: (id, pageIndex = 1, pageSize = 20) => request(`/app/categories/${encodeURIComponent(id)}/dramas?pageIndex=${pageIndex}&pageSize=${pageSize}`),
|
||||
dramas: (body = {}) => request('/app/dramas/page', { method: 'POST', body: JSON.stringify({ pageIndex: 1, pageSize: 20, categoryIds: [], classificationIds: [], tagIds: [], ...body }) }),
|
||||
drama: (id) => request(`/app/dramas/${encodeURIComponent(id)}`),
|
||||
episodePlay: (id) => request(`/app/episodes/${encodeURIComponent(id)}/play`),
|
||||
episodeStats: (id) => request(`/app/episodes/${encodeURIComponent(id)}/stats`),
|
||||
search: (keyword) => request(`/app/search?keyword=${encodeURIComponent(keyword)}&pageIndex=1&pageSize=48`),
|
||||
progress: (body) => request('/app/watch/progress', { method: 'POST', body: JSON.stringify(body) }),
|
||||
watchRecent: (pageIndex = 1, pageSize = 20) => request(`/app/watch/recent?pageIndex=${pageIndex}&pageSize=${pageSize}`),
|
||||
clearWatchHistory: () => request('/app/watch/progress', { method: 'DELETE' }),
|
||||
toggleLike: (targetType, targetId) => request('/app/likes/toggle', { method: 'POST', body: JSON.stringify({ targetType, targetId }) }),
|
||||
toggleFavorite: (dramaId) => request('/app/favorites/toggle', { method: 'POST', body: JSON.stringify({ dramaId }) }),
|
||||
comments: ({ dramaId, episodeId, size = 30 }) => {
|
||||
const params = new URLSearchParams({ dramaId: String(dramaId), size: String(size) });
|
||||
if (episodeId) params.set('episodeId', String(episodeId));
|
||||
return request(`/app/comments?${params.toString()}`);
|
||||
},
|
||||
publishComment: ({ dramaId, episodeId, content }) => request('/app/comments', { method: 'POST', body: JSON.stringify({ dramaId, ...(episodeId ? { episodeId } : {}), content }) }),
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
import { getDeviceId } from './device';
|
||||
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '');
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
|
||||
export async function request(path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: { Accept: 'application/json', 'X-Device-Id': getDeviceId(), 'X-Client-Platform': 'web', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers || {}) },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || (payload.code !== undefined && payload.code !== 0 && payload.code !== 200)) {
|
||||
throw new Error(payload.message || `Request failed (${response.status})`);
|
||||
}
|
||||
return payload.data === undefined ? payload : payload.data;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
const FALLBACK_IMAGE = '/assets/hero.jpg';
|
||||
const MEDIA_BASE_URL = (import.meta.env.VITE_MEDIA_BASE_URL || '').replace(/\/$/, '');
|
||||
const getText = (value, lang = 'id') => (typeof value === 'string' ? value : value?.[lang] || value?.en || value?.id || value?.zh || '');
|
||||
const resolveMedia = (value) => value ? (/^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`) : FALLBACK_IMAGE;
|
||||
const getImage = (item) => resolveMedia(item.coverLandscape || item.coverUrl || item.cover || item.posterUrl || item.imageUrl);
|
||||
|
||||
export function normalizeDrama(item, lang = 'id') {
|
||||
const id = String(item.id ?? item.dramaId ?? item.code ?? '');
|
||||
const episodes = item.episodes || item.episodeList || (item.preview ? [item.preview] : []);
|
||||
const preview = item.preview || episodes[0] || null;
|
||||
return { id, title: getText(item.titleLocales || item.locales || item.title, lang) || item.name || 'YMB Drama', channel: getText(item.channelLocales || item.channel, lang) || item.creatorName || 'YMB Originals', genre: getText(item.categoryLocales || item.genre, lang) || getText(item.types?.[0], lang), image: getImage(item), duration: item.duration || preview?.duration || '', views: item.views || item.viewCount || '', likeCount: Number(item.likeCount) || 0, favoriteCount: Number(item.favoriteCount) || 0, commentCount: Number(item.commentCount) || 0, description: getText(item.synopsisLocales, lang) || '', episodes: episodes.map((episode) => ({ ...episode, playUrl: resolveMedia(episode.playUrl) })), preview: preview ? { ...preview, playUrl: resolveMedia(preview.playUrl) } : null };
|
||||
}
|
||||
|
||||
export function normalizePage(payload) {
|
||||
if (Array.isArray(payload)) return { total: payload.length, list: payload };
|
||||
return { total: payload?.total || 0, list: payload?.list || payload?.records || [] };
|
||||
}
|
||||
|
||||
export function resolvePlaybackUrl(value) { return resolveMedia(value); }
|
||||
|
||||
export function uniqueDramas(items) { const seen = new Set(); return items.filter((item) => { if (seen.has(item.id)) return false; seen.add(item.id); return true; }); }
|
||||
|
||||
export function selectPlaybackUrl(playback, fallback = '') { const tracks = playback?.tracks || []; const preferred = tracks.find((track) => track.quality === '360p') || tracks.find((track) => track.quality === 'AUTO') || tracks[0]; return resolveMedia(preferred?.playUrl || playback?.playUrl || fallback); }
|
||||
|
||||
export function normalizeProgress(item, lang = 'id') {
|
||||
const durationMs = Number(item.durationMs) || 0;
|
||||
const positionMs = Number(item.positionMs) || 0;
|
||||
return {
|
||||
id: String(item.dramaId ?? ''),
|
||||
episodeId: String(item.episodeId ?? ''),
|
||||
title: getText(item.titleLocales, lang) || item.dramaTitle || 'YMB Drama',
|
||||
image: resolveMedia(item.dramaCoverLandscape || item.dramaCover || ''),
|
||||
episodeNumber: item.episodeNumber == null ? null : Number(item.episodeNumber),
|
||||
totalEpisodes: item.totalEpisodes == null ? null : Number(item.totalEpisodes),
|
||||
positionMs,
|
||||
durationMs,
|
||||
fraction: durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0,
|
||||
viewCount: item.viewCount,
|
||||
finished: Boolean(item.finished),
|
||||
updatedAt: item.updatedAt || '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Author: fruit.com.cn
|
||||
import { API_CONFIG } from './config';
|
||||
import { ApiError, CanceledError, NetworkError, TimeoutError } from './errors';
|
||||
|
||||
function mergeHeaders(base, extra) {
|
||||
const headers = { ...base, ...extra };
|
||||
Object.keys(headers).forEach((key) => {
|
||||
if (headers[key] === undefined || headers[key] === null) delete headers[key];
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function runInterceptors(interceptors, value) {
|
||||
let result = value;
|
||||
for (const interceptor of interceptors) result = await interceptor(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createHttpClient(overrides = {}) {
|
||||
const config = {
|
||||
...API_CONFIG,
|
||||
...overrides,
|
||||
headers: mergeHeaders(API_CONFIG.headers, overrides.headers),
|
||||
};
|
||||
const requestInterceptors = [];
|
||||
const responseInterceptors = [];
|
||||
const errorInterceptors = [];
|
||||
|
||||
const client = {
|
||||
config,
|
||||
useRequest(interceptor) {
|
||||
requestInterceptors.push(interceptor);
|
||||
return client;
|
||||
},
|
||||
useResponse(interceptor) {
|
||||
responseInterceptors.push(interceptor);
|
||||
return client;
|
||||
},
|
||||
useError(interceptor) {
|
||||
errorInterceptors.push(interceptor);
|
||||
return client;
|
||||
},
|
||||
async request(path, options = {}) {
|
||||
const { signal: externalSignal, body, ...rest } = options;
|
||||
const requestConfig = await runInterceptors(requestInterceptors, {
|
||||
method: 'GET',
|
||||
...rest,
|
||||
path,
|
||||
body,
|
||||
headers: mergeHeaders(config.headers, rest.headers),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, config.timeout);
|
||||
const abortFromOutside = () => controller.abort();
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) controller.abort();
|
||||
else externalSignal.addEventListener('abort', abortFromOutside);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.baseUrl}${requestConfig.path}`, {
|
||||
method: requestConfig.method,
|
||||
headers: requestConfig.headers,
|
||||
body: requestConfig.body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ApiError(payload?.message || `Request failed (${response.status})`, {
|
||||
status: response.status,
|
||||
code: payload?.code ?? null,
|
||||
path: requestConfig.path,
|
||||
});
|
||||
}
|
||||
if (payload && typeof payload === 'object' && 'code' in payload && !config.successCodes.includes(payload.code)) {
|
||||
throw new ApiError(payload.message || 'Request failed', {
|
||||
status: response.status,
|
||||
code: payload.code,
|
||||
path: requestConfig.path,
|
||||
});
|
||||
}
|
||||
const data = payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload;
|
||||
return await runInterceptors(responseInterceptors, data);
|
||||
} catch (caught) {
|
||||
let error = caught;
|
||||
if (caught?.name === 'AbortError') {
|
||||
error = timedOut || !externalSignal?.aborted
|
||||
? new TimeoutError(requestConfig.path, config.timeout)
|
||||
: new CanceledError(requestConfig.path);
|
||||
} else if (!(caught instanceof ApiError)) {
|
||||
error = new NetworkError(requestConfig.path, caught);
|
||||
}
|
||||
const normalized = await runInterceptors(errorInterceptors, error);
|
||||
throw normalized;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timer);
|
||||
externalSignal?.removeEventListener?.('abort', abortFromOutside);
|
||||
}
|
||||
},
|
||||
get(path, options = {}) {
|
||||
return client.request(path, { ...options, method: 'GET' });
|
||||
},
|
||||
post(path, body, options = {}) {
|
||||
return client.request(path, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
delete(path, options = {}) {
|
||||
return client.request(path, { ...options, method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Author: fruit.com.cn
|
||||
export const API_CONFIG = {
|
||||
baseUrl: (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, ''),
|
||||
timeout: 12000,
|
||||
successCodes: [0, 200],
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Client-Platform': 'web',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// Author: fruit.com.cn
|
||||
import { http } from './index';
|
||||
import { toQuery } from './query';
|
||||
|
||||
export const engagementApi = {
|
||||
toggleLike: (targetType, targetId, options) => http.post('/app/likes/toggle', { targetType, targetId }, options),
|
||||
toggleFavorite: (dramaId, options) => http.post('/app/favorites/toggle', { dramaId }, options),
|
||||
comments: ({ dramaId, episodeId, size = 30 }, options) => http.get(`/app/comments${toQuery({ dramaId, episodeId, size })}`, options),
|
||||
publishComment: ({ dramaId, episodeId, content }, options) => http.post('/app/comments', { dramaId, ...(episodeId ? { episodeId } : {}), content }, options),
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
// Author: fruit.com.cn
|
||||
export class ApiError extends Error {
|
||||
constructor(message, { status = 0, code = null, path = '', cause } = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends ApiError {
|
||||
constructor(path, timeout) {
|
||||
super(`Request timed out after ${timeout}ms`, { path });
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends ApiError {
|
||||
constructor(path, cause) {
|
||||
super('Network request failed', { path, cause });
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CanceledError extends ApiError {
|
||||
constructor(path) {
|
||||
super('Request canceled', { path });
|
||||
this.name = 'CanceledError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isCanceled(error) {
|
||||
return error instanceof CanceledError || error?.name === 'CanceledError' || error?.name === 'AbortError';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Author: fruit.com.cn
|
||||
import { createHttpClient } from './client';
|
||||
import { attachDefaultInterceptors } from './interceptors';
|
||||
|
||||
export { API_CONFIG } from './config';
|
||||
export { createHttpClient } from './client';
|
||||
export { ApiError, CanceledError, NetworkError, TimeoutError, isCanceled } from './errors';
|
||||
|
||||
export const http = attachDefaultInterceptors(createHttpClient());
|
||||
@@ -0,0 +1,22 @@
|
||||
// Author: fruit.com.cn
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
export function attachDefaultInterceptors(client) {
|
||||
client.useRequest((request) => ({
|
||||
...request,
|
||||
headers: {
|
||||
'X-Device-Id': getDeviceId(),
|
||||
...(request.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...request.headers,
|
||||
},
|
||||
}));
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
client.useError((error) => {
|
||||
console.warn(`[api] ${error.name} ${error.path}`, error.message);
|
||||
return error;
|
||||
});
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Author: fruit.com.cn
|
||||
export function toQuery(params = {}) {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
|
||||
});
|
||||
const query = search.toString();
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Author: fruit.com.cn
|
||||
import { http } from './index';
|
||||
import { toQuery } from './query';
|
||||
|
||||
export const watchApi = {
|
||||
report: (body, options) => http.post('/app/watch/progress', body, options),
|
||||
recent: (pageIndex = 1, pageSize = 20, options) => http.get(`/app/watch/recent${toQuery({ pageIndex, pageSize })}`, options),
|
||||
clear: (options) => http.delete('/app/watch/progress', options),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
// Author: fruit.com.cn
|
||||
export function EmptyState({ message }) {
|
||||
return <div className="api-note">{message}</div>;
|
||||
}
|
||||
|
||||
export function ErrorState({ message }) {
|
||||
return <div className="api-note warning">{message}</div>;
|
||||
}
|
||||
|
||||
/** 统一 loading / error / empty 三态渲染。 */
|
||||
export function AsyncBoundary({ loading, error, empty, skeleton = null, errorMessage, emptyMessage, children }) {
|
||||
if (loading) return skeleton;
|
||||
if (error) return <ErrorState message={errorMessage || error.message} />;
|
||||
if (empty) return <EmptyState message={emptyMessage} />;
|
||||
return children;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Send, X } from 'lucide-react';
|
||||
import { appApi } from '../services/appApi';
|
||||
import { engagementApi } from '../api/engagement';
|
||||
|
||||
function formatTime(value, htmlLang) {
|
||||
if (!value) return '';
|
||||
@@ -16,7 +16,7 @@ export function CommentsSheet({ video, locale, onClose, onPosted }) {
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const loadComments = () => appApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
|
||||
const loadComments = () => engagementApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
|
||||
.then((payload) => setComments(payload?.list || []))
|
||||
.catch((loadError) => { setError(loadError.message || 'Request failed'); setComments([]); });
|
||||
|
||||
@@ -24,7 +24,7 @@ export function CommentsSheet({ video, locale, onClose, onPosted }) {
|
||||
let active = true;
|
||||
setComments(null);
|
||||
setError('');
|
||||
appApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
|
||||
engagementApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
|
||||
.then((payload) => active && setComments(payload?.list || []))
|
||||
.catch((loadError) => active && setError(loadError.message || 'Request failed'));
|
||||
return () => { active = false; };
|
||||
@@ -37,7 +37,7 @@ export function CommentsSheet({ video, locale, onClose, onPosted }) {
|
||||
setSending(true);
|
||||
setError('');
|
||||
try {
|
||||
await appApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content });
|
||||
await engagementApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content });
|
||||
setText('');
|
||||
await loadComments();
|
||||
onPosted?.();
|
||||
@@ -1,11 +1,11 @@
|
||||
// Author: fruit.com.cn
|
||||
import { MoreVertical, Play } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAppStore } from '../app/store';
|
||||
import { useLocale } from '../../app/providers/AppProviders';
|
||||
import { daysAgoLabel, formatViews } from '../lib/format';
|
||||
|
||||
export function MediaCard({ video, compact = false, to }) {
|
||||
const { locale } = useAppStore();
|
||||
const { locale } = useLocale();
|
||||
const image = video.image?.startsWith('/') || video.image?.startsWith('http') ? video.image : `/assets/${video.image}`;
|
||||
const target = to || `/watch/${video.id}`;
|
||||
const meta = [formatViews(video.views, locale.code), daysAgoLabel(locale.code)].filter(Boolean).join(' · ');
|
||||
@@ -0,0 +1,10 @@
|
||||
// Author: fruit.com.cn
|
||||
import { MediaCard } from './MediaCard';
|
||||
|
||||
export function VideoGrid({ items = [] }) {
|
||||
return (
|
||||
<div className="video-grid">
|
||||
{items.map((video) => <MediaCard key={video.id} video={video} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react';
|
||||
|
||||
const CONTROLS_HIDE_DELAY = 2600;
|
||||
const SOUND_KEY = 'ymb.player.sound';
|
||||
|
||||
function readSoundPreference() {
|
||||
try {
|
||||
return window.localStorage.getItem(SOUND_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 跨分集/切视频保持用户的音量偏好:一旦开过声音,后续自动播放也带声音
|
||||
let soundEnabled = readSoundPreference();
|
||||
|
||||
function persistSoundPreference(value) {
|
||||
soundEnabled = value;
|
||||
try {
|
||||
window.localStorage.setItem(SOUND_KEY, value ? '1' : '0');
|
||||
} catch {
|
||||
// 隐私模式下仅保持本次会话生效
|
||||
}
|
||||
}
|
||||
|
||||
// 路由切换/组件卸载时彻底停掉解码与音频,避免离开页面后仍有声音
|
||||
function stopVideo(video) {
|
||||
if (!video) return;
|
||||
try {
|
||||
video.pause();
|
||||
video.muted = true;
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
} catch {
|
||||
// 媒体已销毁时忽略
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const total = Math.floor(seconds);
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ src, poster, autoPlay = true, muted = true, onProgress }) {
|
||||
const wrapRef = useRef(null);
|
||||
const videoRef = useRef(null);
|
||||
const hideTimerRef = useRef(null);
|
||||
const draggingRef = useRef(false);
|
||||
const playedRef = useRef(false);
|
||||
const onProgressRef = useRef(onProgress);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(() => (soundEnabled ? false : muted));
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [volumeOpen, setVolumeOpen] = useState(false);
|
||||
const [hoverRatio, setHoverRatio] = useState(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onProgressRef.current = onProgress;
|
||||
}, [onProgress]);
|
||||
|
||||
// 暂停/离开页面时立即上报一次进度
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return undefined;
|
||||
const flush = () => {
|
||||
if (playedRef.current) onProgressRef.current?.(video.currentTime, video.duration, true);
|
||||
};
|
||||
// 关闭标签/切到后台时立即停止播放,避免被浏览器接管为画中画或后台音频
|
||||
const onPageHide = () => {
|
||||
flush();
|
||||
video.pause();
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.hidden) video.pause();
|
||||
};
|
||||
video.addEventListener('pause', flush);
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => {
|
||||
flush();
|
||||
video.removeEventListener('pause', flush);
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !src) return undefined;
|
||||
playedRef.current = false;
|
||||
setReady(false);
|
||||
setError('');
|
||||
setPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setBuffered(0);
|
||||
const hls = new Hls({ enableWorker: true });
|
||||
const start = () => setReady(true);
|
||||
const onHlsError = (_event, data) => {
|
||||
if (!data?.fatal) return;
|
||||
setError(`${data.details || 'Video stream error'} · ${data.response?.code || ''}`);
|
||||
console.error('YMB HLS ERROR', data);
|
||||
};
|
||||
const onVideoError = () => {
|
||||
const message = `VIDEO_ERROR: ${video.error?.code || 'unknown'} ${video.error?.message || ''}`;
|
||||
console.error(message, { src, mediaError: video.error });
|
||||
setError(message);
|
||||
};
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, start);
|
||||
hls.on(Hls.Events.ERROR, onHlsError);
|
||||
video.addEventListener('error', onVideoError);
|
||||
if (Hls.isSupported()) {
|
||||
hls.attachMedia(video);
|
||||
hls.loadSource(src);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src;
|
||||
video.addEventListener('loadedmetadata', start, { once: true });
|
||||
} else {
|
||||
setError('This browser does not support HLS');
|
||||
}
|
||||
return () => {
|
||||
video.removeEventListener('error', onVideoError);
|
||||
hls.destroy();
|
||||
stopVideo(video);
|
||||
};
|
||||
}, [src, reloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const nextMuted = muted && !soundEnabled;
|
||||
video.muted = nextMuted;
|
||||
setIsMuted(nextMuted);
|
||||
}, [muted]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !ready) return;
|
||||
if (!autoPlay) {
|
||||
video.pause();
|
||||
return;
|
||||
}
|
||||
const wantSound = soundEnabled;
|
||||
video.muted = !wantSound;
|
||||
setIsMuted(!wantSound);
|
||||
video.play().catch((playError) => {
|
||||
if (playError.name === 'AbortError') return;
|
||||
// 浏览器拦截带声音的自动播放时,降级为静音播放
|
||||
if (playError.name === 'NotAllowedError' && wantSound) {
|
||||
video.muted = true;
|
||||
setIsMuted(true);
|
||||
video.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
setError(`PLAY_FAILED: ${playError.message}`);
|
||||
});
|
||||
}, [autoPlay, ready]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(Boolean(document.fullscreenElement));
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
return () => {
|
||||
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current);
|
||||
stopVideo(video);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const revealControls = () => {
|
||||
setControlsVisible(true);
|
||||
if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
if (videoRef.current && !videoRef.current.paused) setControlsVisible(false);
|
||||
}, CONTROLS_HIDE_DELAY);
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
video.muted = false;
|
||||
setIsMuted(false);
|
||||
persistSoundPreference(true);
|
||||
video.play().catch((playError) => {
|
||||
if (playError.name !== 'AbortError') setError(`PLAY_FAILED: ${playError.message}`);
|
||||
});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
revealControls();
|
||||
};
|
||||
|
||||
const seekToRatio = (ratio) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !Number.isFinite(video.duration)) return;
|
||||
video.currentTime = Math.min(Math.max(ratio, 0), 1) * video.duration;
|
||||
};
|
||||
|
||||
const seekFromPointer = (event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
seekToRatio((event.clientX - rect.left) / rect.width);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.muted = !video.muted;
|
||||
setIsMuted(video.muted);
|
||||
persistSoundPreference(!video.muted);
|
||||
};
|
||||
|
||||
const changeVolume = (event) => {
|
||||
const next = Number(event.target.value);
|
||||
const video = videoRef.current;
|
||||
setVolume(next);
|
||||
if (!video) return;
|
||||
video.volume = next;
|
||||
video.muted = next === 0;
|
||||
setIsMuted(next === 0);
|
||||
persistSoundPreference(next > 0);
|
||||
};
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const node = wrapRef.current;
|
||||
if (!node) return;
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
else await node.requestFullscreen();
|
||||
} catch {
|
||||
// 全屏被浏览器拒绝时保持当前状态
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (event.key === ' ' || event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
togglePlay();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
video.currentTime = Math.max(0, video.currentTime - 5);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
video.currentTime = Math.min(video.duration || 0, video.currentTime + 5);
|
||||
} else if (event.key.toLowerCase() === 'm') {
|
||||
toggleMute();
|
||||
} else if (event.key.toLowerCase() === 'f') {
|
||||
toggleFullscreen();
|
||||
}
|
||||
revealControls();
|
||||
};
|
||||
|
||||
const percent = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||
const bufferedPercent = duration > 0 ? Math.min(100, (buffered / duration) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={`vp video-player-wrap ${controlsVisible ? 'show-controls' : ''} ${playing ? 'is-playing' : ''}`}
|
||||
onMouseMove={revealControls}
|
||||
onMouseLeave={() => playing && setControlsVisible(false)}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label="Video player"
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="video-player"
|
||||
poster={poster}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
muted={muted}
|
||||
onClick={togglePlay}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onPlay={() => { setPlaying(true); revealControls(); }}
|
||||
onPause={() => { setPlaying(false); setControlsVisible(true); }}
|
||||
onWaiting={() => setReady(false)}
|
||||
onPlaying={() => setReady(true)}
|
||||
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
|
||||
onTimeUpdate={(event) => {
|
||||
const video = event.currentTarget;
|
||||
if (video.currentTime > 0) playedRef.current = true;
|
||||
setCurrentTime(video.currentTime);
|
||||
setDuration(video.duration || 0);
|
||||
if (video.buffered.length) setBuffered(video.buffered.end(video.buffered.length - 1));
|
||||
onProgressRef.current?.(video.currentTime, video.duration, false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{!ready && !error && <span className="vp-spinner" aria-hidden="true" />}
|
||||
|
||||
{error && (
|
||||
<button
|
||||
className="vp-error"
|
||||
type="button"
|
||||
onClick={() => { setError(''); setReloadKey((key) => key + 1); }}
|
||||
>
|
||||
{error} · 点击重试
|
||||
</button>
|
||||
)}
|
||||
|
||||
{ready && !playing && !error && (
|
||||
<button className="vp-big-play" type="button" onClick={togglePlay} aria-label="Play">
|
||||
<Play size={26} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="vp-controls" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
className="vp-progress"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(percent)}
|
||||
aria-label="Progress"
|
||||
onPointerDown={(event) => {
|
||||
draggingRef.current = true;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
seekFromPointer(event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (rect.width) setHoverRatio(Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1));
|
||||
if (draggingRef.current) seekFromPointer(event);
|
||||
}}
|
||||
onPointerLeave={() => setHoverRatio(null)}
|
||||
onPointerUp={() => { draggingRef.current = false; }}
|
||||
onPointerCancel={() => { draggingRef.current = false; }}
|
||||
>
|
||||
<span className="vp-progress-track">
|
||||
<i className="vp-buffered" style={{ width: `${bufferedPercent}%` }} />
|
||||
<i className="vp-played" style={{ width: `${percent}%` }} />
|
||||
</span>
|
||||
<span className="vp-thumb" style={{ left: `${percent}%` }} />
|
||||
{hoverRatio !== null && duration > 0 && (
|
||||
<span className="vp-progress-tip" style={{ left: `${hoverRatio * 100}%` }}>
|
||||
{formatTime(hoverRatio * duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="vp-row">
|
||||
<button className="vp-btn" type="button" onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
|
||||
{playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
||||
</button>
|
||||
<span className="vp-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||
<span className="vp-spacer" />
|
||||
|
||||
<div className="vp-volume" onMouseEnter={() => setVolumeOpen(true)} onMouseLeave={() => setVolumeOpen(false)}>
|
||||
<button className="vp-btn" type="button" onClick={toggleMute} aria-label={isMuted ? 'Unmute' : 'Mute'}>
|
||||
{isMuted ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
</button>
|
||||
<span className={`vp-volume-slider ${volumeOpen ? 'open' : ''}`}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button className="vp-btn" type="button" onClick={toggleFullscreen} aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
|
||||
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useState } from 'react';
|
||||
import { engagementApi } from '../api/engagement';
|
||||
import { useMutation } from './useMutation';
|
||||
|
||||
/**
|
||||
* 点赞 / 收藏状态与交互,Shorts 与播放页共用。
|
||||
* likeTargetType: 'EPISODE' | 'DRAMA'
|
||||
*/
|
||||
export function useEngagement({ likeTargetType, likeTargetId, dramaId, initialLikeCount = 0, initialSaveCount = 0 }) {
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(Number(initialLikeCount) || 0);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [saveCount, setSaveCount] = useState(Number(initialSaveCount) || 0);
|
||||
const likeMutation = useMutation(() => engagementApi.toggleLike(likeTargetType, likeTargetId));
|
||||
const saveMutation = useMutation(() => engagementApi.toggleFavorite(dramaId));
|
||||
|
||||
const toggleLike = async () => {
|
||||
try {
|
||||
const result = await likeMutation.run();
|
||||
setLiked(Boolean(result?.liked));
|
||||
setLikeCount(Number(result?.count) || 0);
|
||||
} catch {
|
||||
// 交互失败时保持原状态,避免误导用户
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSave = async () => {
|
||||
try {
|
||||
const next = await saveMutation.run();
|
||||
setSaved(Boolean(next));
|
||||
setSaveCount((count) => Math.max(0, count + (next ? 1 : -1)));
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
};
|
||||
|
||||
const syncStats = (stats) => {
|
||||
if (!stats) return;
|
||||
setLiked(Boolean(stats.liked));
|
||||
setLikeCount(Number(stats.likeCount) || 0);
|
||||
setSaved(Boolean(stats.favorited));
|
||||
setSaveCount(Number(stats.favoriteCount) || 0);
|
||||
};
|
||||
|
||||
return {
|
||||
liked,
|
||||
likeCount,
|
||||
saved,
|
||||
saveCount,
|
||||
toggleLike,
|
||||
toggleSave,
|
||||
syncStats,
|
||||
pending: likeMutation.pending || saveMutation.pending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
/** 统一写操作 hook:暴露 pending / error,run 失败会抛错由调用方决定提示方式。 */
|
||||
export function useMutation(mutator) {
|
||||
const [state, setState] = useState({ pending: false, error: null });
|
||||
const mutatorRef = useRef(mutator);
|
||||
mutatorRef.current = mutator;
|
||||
|
||||
const run = useCallback(async (...args) => {
|
||||
setState({ pending: true, error: null });
|
||||
try {
|
||||
const data = await mutatorRef.current(...args);
|
||||
setState({ pending: false, error: null });
|
||||
return data;
|
||||
} catch (error) {
|
||||
setState({ pending: false, error });
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { ...state, run };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { isCanceled } from '../api';
|
||||
|
||||
/**
|
||||
* 统一查询 hook:支持取消、错误对象、重试(refetch)与禁用。
|
||||
* fetcher 接收 { signal },应把 signal 透传给 api 方法。
|
||||
*/
|
||||
export function useRequest(fetcher, deps = [], { enabled = true, initialData = null } = {}) {
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [state, setState] = useState({ data: initialData, loading: enabled, error: null });
|
||||
const fetcherRef = useRef(fetcher);
|
||||
fetcherRef.current = fetcher;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState({ data: initialData, loading: false, error: null });
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
Promise.resolve()
|
||||
.then(() => fetcherRef.current({ signal: controller.signal }))
|
||||
.then((data) => {
|
||||
if (active) setState({ data, loading: false, error: null });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active || isCanceled(error)) return;
|
||||
setState({ data: initialData, loading: false, error });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, enabled, reloadKey]);
|
||||
|
||||
const refetch = useCallback(() => setReloadKey((key) => key + 1), []);
|
||||
return { ...state, refetch };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Author: fruit.com.cn
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useToast(duration = 2000) {
|
||||
const [message, setMessage] = useState('');
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const show = useCallback((text) => {
|
||||
setMessage(text);
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => setMessage(''), duration);
|
||||
}, [duration]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
setMessage('');
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
return { message, show, hide };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Author: fruit.com.cn
|
||||
const GEO_ENDPOINT = 'https://ipwho.is/?fields=country_code';
|
||||
const GEO_TIMEOUT_MS = 4000;
|
||||
const CHINESE_REGIONS = ['CN', 'TW', 'HK', 'MO'];
|
||||
|
||||
/** 根据 IP 归属地推断站点语言;失败返回空字符串,由调用方回退。 */
|
||||
export async function detectLocaleFromIp() {
|
||||
const controller = new AbortController();
|
||||
const timer = globalThis.setTimeout(() => controller.abort(), GEO_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(GEO_ENDPOINT, { signal: controller.signal });
|
||||
if (!response.ok) return '';
|
||||
const payload = await response.json();
|
||||
const country = typeof payload?.country_code === 'string' ? payload.country_code.toUpperCase() : '';
|
||||
if (!country) return '';
|
||||
if (country === 'ID') return 'id';
|
||||
if (CHINESE_REGIONS.includes(country)) return 'zh';
|
||||
return 'en';
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
globalThis.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Author: fruit.com.cn
|
||||
export const FALLBACK_IMAGE = '/assets/hero.jpg';
|
||||
const MEDIA_BASE_URL = (import.meta.env.VITE_MEDIA_BASE_URL || '').replace(/\/$/, '');
|
||||
|
||||
export const resolveMedia = (value) => {
|
||||
if (!value) return FALLBACK_IMAGE;
|
||||
return /^https?:\/\//.test(value) ? value : `${MEDIA_BASE_URL}${value}`;
|
||||
};
|
||||
|
||||
export const resolvePlaybackUrl = resolveMedia;
|
||||
|
||||
export const getImage = (item = {}) => resolveMedia(item.coverLandscape || item.coverUrl || item.cover || item.posterUrl || item.imageUrl);
|
||||
|
||||
export function selectPlaybackUrl(playback, fallback = '') {
|
||||
const tracks = playback?.tracks || [];
|
||||
const preferred = tracks.find((track) => track.quality === '360p') || tracks.find((track) => track.quality === 'AUTO') || tracks[0];
|
||||
return resolveMedia(preferred?.playUrl || playback?.playUrl || fallback);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
export function normalizePage(payload) {
|
||||
if (Array.isArray(payload)) return { total: payload.length, list: payload };
|
||||
return { total: payload?.total || 0, list: payload?.list || payload?.records || [] };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
export function getText(value, lang = 'id') {
|
||||
if (typeof value === 'string') return value;
|
||||
return value?.[lang] || value?.en || value?.id || value?.zh || '';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
import { appApi } from './appApi';
|
||||
import { watchApi } from '../api/watch';
|
||||
|
||||
const REPORT_INTERVAL_MS = 10000;
|
||||
const lastReportedAt = new Map();
|
||||
@@ -13,7 +13,7 @@ export function reportWatchProgress(episodeId, positionSeconds, durationSeconds,
|
||||
lastReportedAt.set(id, now);
|
||||
const positionMs = Math.max(0, Math.round((Number(positionSeconds) || 0) * 1000));
|
||||
const durationMs = Math.max(0, Math.round((Number(durationSeconds) || 0) * 1000));
|
||||
appApi.progress({
|
||||
watchApi.report({
|
||||
episodeId: id,
|
||||
positionMs,
|
||||
...(durationMs > 0 ? { durationMs, finished: durationMs - positionMs < 1000 } : {}),
|
||||
@@ -66,7 +66,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.language-trigger:hover, .language-trigger[aria-expanded="true"] { background: #272727; color: #fff; }
|
||||
.language-trigger svg { color: #b9b9b9; }
|
||||
.language-trigger .language-code { color: #fff; font-size: 12px; font-weight: 600; }
|
||||
.language-trigger .language-flag { display: inline-flex; flex: none; width: 20px; height: 14px; border-radius: 2px; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; }
|
||||
.language-trigger .language-flag { display: inline-flex; flex: none; width: 20px; height: 20px; border-radius: 50%; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; }
|
||||
.language-flag svg { display: block; width: 100%; height: 100%; }
|
||||
.language-trigger .language-chevron { color: #8a8a8a; }
|
||||
.profile-chip { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: #065fd4; color: #fff; }
|
||||
@@ -128,7 +128,7 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.language-menu button.selected { background: #ffffff0f; color: #fff; }
|
||||
.language-option-label { flex: 1; font-size: 14px; }
|
||||
.language-option-check { flex: none; color: #ff8a82; }
|
||||
.language-menu .language-flag { display: inline-flex; flex: none; width: 22px; height: 15px; border-radius: 2px; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; }
|
||||
.language-menu .language-flag { display: inline-flex; flex: none; width: 22px; height: 22px; border-radius: 50%; overflow: hidden; box-shadow: 0 0 0 1px #ffffff1f; }
|
||||
|
||||
/* Shorts:客户端式全屏竖滑视频流 */
|
||||
.shorts-stage { position: fixed; top: var(--topbar); left: var(--sidebar); right: 0; bottom: 0; z-index: 6; margin: 0; overflow: hidden; background: #000; }
|
||||
@@ -182,55 +182,60 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.comment-form button { display: grid; place-items: center; width: 44px; height: 40px; border: 0; border-radius: 20px; background: #ff0033; color: #fff; cursor: pointer; }
|
||||
.comment-form button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
/* 播放页 */
|
||||
.watch-page { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 32px; max-width: 1280px; margin: 0 auto; padding: 8px 0 32px; align-items: start; }
|
||||
.watch-page { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 36px; max-width: 1320px; margin: 0 auto; padding: 8px 0 48px; align-items: start; }
|
||||
.watch-primary { min-width: 0; }
|
||||
.watch-player { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; background: #000; touch-action: none; }
|
||||
.watch-stage { position: relative; display: grid; place-items: center; padding: 22px 20px; overflow: hidden; border-radius: 24px; background: radial-gradient(120% 90% at 50% 0%, #1d1e25, #09090b 72%); box-shadow: 0 30px 80px #0006, inset 0 1px 0 #ffffff0d; }
|
||||
.watch-stage::before { content: ''; position: absolute; inset: -35%; background-image: var(--watch-poster); background-position: center; background-size: cover; filter: blur(56px) saturate(1.4); opacity: .4; transform: scale(1.15); pointer-events: none; }
|
||||
.watch-stage::after { content: ''; position: absolute; inset: 0; background: radial-gradient(72% 72% at 50% 46%, #00000000 20%, #08080ae0 92%); pointer-events: none; }
|
||||
.watch-player { position: relative; z-index: 1; width: auto; max-width: 100%; height: min(64vh, 620px); aspect-ratio: 9 / 16; overflow: hidden; border-radius: 18px; background: #000; box-shadow: 0 40px 100px #000e; touch-action: none; }
|
||||
.watch-player .video-player { width: 100%; height: 100%; object-fit: contain; }
|
||||
.watch-player-placeholder { position: relative; display: grid; place-items: center; width: 100%; height: 100%; }
|
||||
.watch-player-placeholder img { width: 100%; height: 100%; object-fit: cover; opacity: .35; }
|
||||
.watch-player-placeholder span { position: absolute; padding: 10px 14px; border-radius: 8px; background: #000b; color: #fff; }
|
||||
.watch-player-placeholder img { width: 100%; height: 100%; object-fit: cover; opacity: .4; }
|
||||
.watch-player-placeholder span { position: absolute; padding: 10px 14px; border-radius: 999px; background: #000b; color: #fff; font-size: 13px; backdrop-filter: blur(6px); }
|
||||
.watch-swipe-tip { position: absolute; top: 12px; right: 12px; z-index: 2; display: none; padding: 5px 10px; border-radius: 999px; background: #0009; color: #ddd; font-size: 11px; pointer-events: none; }
|
||||
@media (max-width: 760px) { .watch-swipe-tip { display: block; } }
|
||||
.watch-arrows { position: absolute; top: 50%; right: 12px; z-index: 3; display: grid; gap: 8px; transform: translateY(-50%); }
|
||||
.watch-arrows button { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid #ffffff24; border-radius: 50%; background: #0009; color: #fff; font-size: 16px; cursor: pointer; backdrop-filter: blur(6px); }
|
||||
.watch-arrows button { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 50%; background: #0009; color: #fff; font-size: 16px; cursor: pointer; box-shadow: 0 8px 22px #0008; backdrop-filter: blur(6px); }
|
||||
.watch-arrows button:disabled { opacity: .35; cursor: not-allowed; }
|
||||
@media (max-width: 760px) { .watch-arrows { display: none; } }
|
||||
.watch-title { margin: 16px 0 12px; color: #fff; font-size: 20px; font-weight: 600; line-height: 1.4; }
|
||||
.watch-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.watch-meta { display: flex; align-items: center; gap: 10px; margin: 22px 2px 10px; color: #9a9a9a; font-size: 12.5px; }
|
||||
.watch-badge { padding: 4px 10px; border-radius: 999px; background: #ffffff10; color: #eaeaea; font-size: 12px; font-weight: 600; letter-spacing: .02em; }
|
||||
.watch-title { margin: 0 0 18px; color: #fff; font-size: 26px; font-weight: 700; line-height: 1.35; letter-spacing: -.015em; }
|
||||
.watch-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0 16px; border-top: 1px solid #ffffff0d; border-bottom: 1px solid #ffffff0d; }
|
||||
.watch-channel { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.watch-avatar { display: grid; place-items: center; flex: none; width: 40px; height: 40px; border-radius: 50%; background: #065fd4; color: #fff; font-size: 15px; font-weight: 700; }
|
||||
.watch-avatar { display: grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: 50%; background: linear-gradient(135deg, #2b6cff, #7a3cff); color: #fff; font-size: 15px; font-weight: 700; box-shadow: 0 0 0 2px #0f0f0f, 0 0 0 3px #ffffff1f; }
|
||||
.watch-channel-copy { min-width: 0; }
|
||||
.watch-channel-copy b { color: #fff; font-size: 14px; font-weight: 600; }
|
||||
.watch-subscribe { margin-left: 2px; border: 0; border-radius: 20px; padding: 8px 16px; background: #fff; color: #111; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.watch-subscribe.followed { background: #272727; color: #fff; }
|
||||
.watch-channel-copy b { color: #f5f5f5; font-size: 14px; font-weight: 600; }
|
||||
.watch-subscribe { margin-left: 2px; border: 0; border-radius: 999px; padding: 9px 20px; background: #fff; color: #0b0b0b; font-size: 13px; font-weight: 700; cursor: pointer; transition: transform .18s ease, background .18s ease; }
|
||||
.watch-subscribe:hover { transform: translateY(-1px); }
|
||||
.watch-subscribe.followed { background: #ffffff14; color: #e8e8e8; box-shadow: inset 0 0 0 1px #ffffff24; }
|
||||
.watch-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.watch-actions button { display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; border-radius: 18px; background: #272727; color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.watch-actions button:hover { background: #343434; }
|
||||
.watch-actions button.active { background: #ff0033; }
|
||||
.watch-description { margin-top: 16px; padding: 12px 14px; border-radius: 12px; background: #1a1a1a; color: #ccc; font-size: 14px; line-height: 1.6; }
|
||||
.watch-description b { display: block; margin-bottom: 4px; color: #fff; font-size: 13px; font-weight: 600; }
|
||||
.watch-actions button { display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 16px; border: 0; border-radius: 999px; background: #ffffff0f; color: #f0f0f0; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .18s ease, transform .16s ease; }
|
||||
.watch-actions button:hover { background: #ffffff1e; transform: translateY(-1px); }
|
||||
.watch-actions button.active { background: #ff2d55; color: #fff; }
|
||||
.watch-description { margin-top: 18px; padding: 18px 20px; border-radius: 16px; background: linear-gradient(180deg, #17171a, #111113); color: #c7c7c7; font-size: 14px; line-height: 1.75; box-shadow: 0 16px 40px #0005; }
|
||||
.watch-description p { margin: 0; white-space: pre-wrap; }
|
||||
.watch-episode { margin: -6px 0 12px; color: #aaa; font-size: 13px; }
|
||||
.watch-side { min-width: 0; }
|
||||
.watch-side h2 { margin: 0 0 12px; color: #fff; font-size: 16px; font-weight: 600; }
|
||||
.watch-episode-list { display: grid; gap: 8px; }
|
||||
.watch-episode-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px; border: 0; border-radius: 10px; background: transparent; text-align: left; cursor: pointer; }
|
||||
.watch-episode-item:hover { background: #1f1f1f; }
|
||||
.watch-episode-item.active { background: #2a2a2a; }
|
||||
.watch-episode-thumb { position: relative; flex: none; width: 112px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: #222; }
|
||||
.watch-side { position: sticky; top: calc(var(--topbar) + 16px); min-width: 0; padding: 18px; border-radius: 20px; background: linear-gradient(180deg, #151518, #101012); box-shadow: 0 24px 60px #0006, inset 0 1px 0 #ffffff0a; }
|
||||
.watch-side h2 { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 0 14px; color: #fff; font-size: 15px; font-weight: 600; letter-spacing: .01em; }
|
||||
.watch-episode-count { padding: 3px 9px; border-radius: 999px; background: #ffffff0d; color: #9a9a9a; font-size: 11px; font-weight: 600; }
|
||||
.watch-episode-list { display: grid; gap: 6px; max-height: calc(100vh - var(--topbar) - 150px); overflow-y: auto; padding-right: 4px; scrollbar-width: thin; scrollbar-color: #3a3a3a transparent; }
|
||||
.watch-episode-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px; border: 0; border-radius: 12px; background: transparent; text-align: left; cursor: pointer; transition: background .18s ease; }
|
||||
.watch-episode-item:hover { background: #ffffff0a; }
|
||||
.watch-episode-item.active { background: #ffffff12; box-shadow: 0 10px 28px #0007; }
|
||||
.watch-episode-thumb { position: relative; flex: none; width: 96px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 8px; background: #222; }
|
||||
.watch-episode-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.watch-episode-thumb b { position: absolute; right: 4px; bottom: 4px; padding: 1px 4px; border-radius: 3px; background: #000c; color: #fff; font-size: 11px; font-weight: 600; }
|
||||
.watch-episode-lock { position: absolute; inset: 0; display: grid; place-items: center; background: #0009; color: #fff; }
|
||||
.watch-episode-lock { position: absolute; inset: 0; display: grid; place-items: center; background: #000a; color: #fff; backdrop-filter: blur(2px); }
|
||||
.watch-episode-copy { display: grid; gap: 3px; min-width: 0; align-content: center; }
|
||||
.watch-episode-copy b { color: #fff; font-size: 13px; font-weight: 500; }
|
||||
.watch-episode-copy small { color: #aaa; font-size: 12px; }
|
||||
.watch-episode-copy b { color: #f2f2f2; font-size: 13px; font-weight: 600; }
|
||||
.watch-episode-copy small { color: #8f8f8f; font-size: 11.5px; }
|
||||
.watch-side-card { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.watch-side-thumb { position: relative; flex: none; width: 112px; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 6px; background: #222; }
|
||||
.watch-skeleton-player { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; }
|
||||
.watch-skeleton-title { width: 72%; height: 26px; margin-top: 18px; }
|
||||
.watch-skeleton-player { width: min(100%, 372px); aspect-ratio: 9 / 16; margin: 0 auto; border-radius: 18px; }
|
||||
.watch-skeleton-title { width: 72%; height: 26px; margin-top: 22px; }
|
||||
.watch-skeleton-row { display: flex; align-items: center; gap: 12px; margin-top: 18px; }
|
||||
.watch-skeleton-channel { width: 160px; height: 36px; border-radius: 18px; }
|
||||
.watch-skeleton-description { height: 96px; margin-top: 18px; border-radius: 12px; }
|
||||
.watch-skeleton-description { height: 96px; margin-top: 18px; border-radius: 16px; }
|
||||
.watch-skeleton-heading { width: 90px; height: 18px; margin-bottom: 16px; }
|
||||
@media (max-width: 1180px) { .watch-page { grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; } }
|
||||
@media (max-width: 980px) { .watch-page { display: block; } .watch-side { margin-top: 28px; } }
|
||||
@@ -272,58 +277,72 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.shorts-skeleton-copy { position: absolute; right: 4px; bottom: 30px; left: 4px; display: grid; gap: 8px; }
|
||||
|
||||
/* 关于 YMB */
|
||||
.about-page { max-width: 1000px; margin: 0 auto; padding: 8px 0 24px; }
|
||||
.about-hero { position: relative; display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(250px, .75fr); gap: 32px; align-items: center; overflow: hidden; padding: 56px 48px; border: 1px solid #2a2a2a; border-radius: 24px; background: linear-gradient(135deg, #0c1a30, #141414 55%, #2a0d16); }
|
||||
.about-hero::before { content: ''; position: absolute; top: -140px; right: -60px; width: 420px; height: 420px; border-radius: 50%; background: radial-gradient(circle, #1f6feb55, transparent 65%); }
|
||||
.about-hero::after { content: ''; position: absolute; bottom: -180px; left: 22%; width: 380px; height: 380px; border-radius: 50%; background: radial-gradient(circle, #ff003344, transparent 65%); }
|
||||
.about-hero-copy, .about-hero-visual { position: relative; z-index: 1; }
|
||||
.about-kicker { display: inline-flex; padding: 5px 10px; border-radius: 999px; background: #ffffff14; color: #9ecbff; font-size: 11px; font-weight: 600; letter-spacing: .14em; }
|
||||
.about-hero h1 { max-width: 640px; margin: 18px 0 12px; color: #fff; font-size: 48px; font-weight: 700; line-height: 1.08; letter-spacing: -.02em; }
|
||||
.about-hero p { max-width: 600px; margin: 0; color: #cfcfcf; font-size: 15px; line-height: 1.7; }
|
||||
.about-hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
|
||||
.about-primary, .about-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 42px; padding: 0 20px; border-radius: 999px; font-size: 14px; font-weight: 600; }
|
||||
.about-primary { background: #fff; color: #111; }
|
||||
.about-secondary { border: 1px solid #ffffff2b; background: #ffffff14; color: #fff; }
|
||||
.about-stores { display: flex; flex-direction: column; gap: 10px; margin-top: 28px; }
|
||||
.about-stores-label { color: #8f8f8f; font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.about-stores-row { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.about-store { display: inline-flex; align-items: center; gap: 10px; padding: 9px 16px; border: 1px solid #ffffff26; border-radius: 12px; background: #ffffff0f; color: #fff; transition: background .18s ease, border-color .18s ease; }
|
||||
.about-store:hover { border-color: #ffffff4d; background: #ffffff1f; }
|
||||
.about-store > span { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.about-store small { color: #9c9c9c; font-size: 10px; }
|
||||
.about-store b { color: #fff; font-size: 13px; font-weight: 600; }
|
||||
.about-values { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 26px; }
|
||||
.about-values span { padding: 5px 10px; border-radius: 999px; background: #ffffff0f; color: #bbb; font-size: 12px; }
|
||||
.about-hero-visual { display: grid; place-items: center; min-height: 330px; }
|
||||
.about-phone { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; width: 188px; height: 322px; border: 1px solid #ffffff24; border-radius: 30px; background: linear-gradient(160deg, #0e2b55, #1b1b1b 60%, #350e19); box-shadow: 0 30px 80px #0008; }
|
||||
.about-phone::before { content: ''; position: absolute; top: 12px; width: 48px; height: 4px; border-radius: 2px; background: #ffffff38; }
|
||||
.about-phone-glow { position: absolute; inset: 12px; border-radius: 22px; background: radial-gradient(circle at 50% 30%, #1f6feb33, transparent 60%); }
|
||||
.about-phone-logo { position: relative; color: #fff; font-size: 34px; font-weight: 800; letter-spacing: -.04em; }
|
||||
.about-phone-play { position: relative; display: grid; place-items: center; width: 56px; height: 56px; border-radius: 50%; background: #ff0033; color: #fff; box-shadow: 0 10px 30px #ff003355; }
|
||||
.about-phone small { position: relative; color: #9ecbff; font-size: 10px; font-weight: 600; letter-spacing: .24em; }
|
||||
.about-float { position: absolute; z-index: 2; padding: 6px 10px; border: 1px solid #ffffff26; border-radius: 999px; background: #ffffff17; color: #eaeaea; font-size: 11px; white-space: nowrap; }
|
||||
.about-float-a { top: 26px; left: 0; }
|
||||
.about-float-b { top: 50%; right: -4px; }
|
||||
.about-float-c { bottom: 24px; left: 12px; }
|
||||
.about-section { margin-top: 40px; }
|
||||
.about-section > h2 { margin: 0 0 12px; color: #fff; font-size: 24px; font-weight: 600; }
|
||||
.about-lead { max-width: 760px; margin: 0; color: #b7b7b7; font-size: 15px; line-height: 1.8; }
|
||||
.about-pillars { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 20px; }
|
||||
.about-pillar { padding: 24px; border: 1px solid #2a2a2a; border-radius: 16px; background: #1a1a1a; }
|
||||
.about-pillar-icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 12px; background: #ffffff10; color: #9ecbff; }
|
||||
.about-pillar h3 { margin: 18px 0 8px; color: #fff; font-size: 17px; font-weight: 600; }
|
||||
.about-pillar p { margin: 0; color: #a8a8a8; font-size: 13px; line-height: 1.7; }
|
||||
.about-roadmap { margin: 16px 0 0; padding: 0; list-style: none; }
|
||||
.about-roadmap li { display: flex; gap: 18px; padding: 18px 0; border-bottom: 1px solid #262626; }
|
||||
.about-roadmap li:last-child { border-bottom: 0; }
|
||||
.about-step-index { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 50%; background: #ffffff10; color: #9ecbff; font-size: 12px; font-weight: 600; }
|
||||
.about-roadmap b { display: block; margin-bottom: 4px; color: #fff; font-size: 15px; font-weight: 600; }
|
||||
.about-roadmap p { margin: 0; color: #a8a8a8; font-size: 13px; line-height: 1.7; }
|
||||
.about-cta { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-top: 44px; padding: 32px; border: 1px solid #2a2a2a; border-radius: 20px; background: linear-gradient(120deg, #12233f, #1b1b1b 55%, #33101a); }
|
||||
.about-cta h2 { margin: 0 0 8px; color: #fff; font-size: 22px; font-weight: 600; }
|
||||
.about-cta p { margin: 0; max-width: 560px; color: #b7b7b7; font-size: 14px; line-height: 1.7; }
|
||||
.about-page { max-width: 1080px; margin: 0 auto; padding: 8px 0 40px; counter-reset: about-section; }
|
||||
|
||||
@media (max-width: 960px) { .about-hero { grid-template-columns: 1fr; padding: 40px 32px; } .about-hero-visual { display: none; } .about-hero h1 { font-size: 38px; } }
|
||||
/* Hero:极光渐变 + 高级质感 */
|
||||
.about-hero { position: relative; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr); gap: 40px; align-items: center; overflow: hidden; padding: 64px 56px; border-radius: 30px; background: radial-gradient(130% 150% at 8% -10%, #182947 0%, #0d0e12 44%, #170b12 100%); box-shadow: 0 50px 130px #0008, inset 0 1px 0 #ffffff12; isolation: isolate; }
|
||||
.about-hero::before { content: ''; position: absolute; top: -280px; right: -160px; width: 620px; height: 620px; border-radius: 50%; background: radial-gradient(circle, #2f6bff4d, transparent 62%); filter: blur(8px); pointer-events: none; }
|
||||
.about-hero::after { content: ''; position: absolute; bottom: -320px; left: 18%; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(circle, #ff2d5540, transparent 64%); filter: blur(8px); pointer-events: none; }
|
||||
.about-hero-copy, .about-hero-visual { position: relative; z-index: 2; }
|
||||
.about-kicker { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: #ffffff12; color: #bfd8ff; font-size: 11px; font-weight: 700; letter-spacing: .16em; backdrop-filter: blur(8px); }
|
||||
.about-kicker::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #8ec5ff; box-shadow: 0 0 12px #8ec5ff; animation: about-pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes about-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } }
|
||||
.about-hero h1 { max-width: 620px; margin: 20px 0 14px; font-size: 58px; font-weight: 800; line-height: 1.04; letter-spacing: -.035em; background: linear-gradient(115deg, #ffffff 15%, #b9d3ff 52%, #ffb7c6 88%); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.about-hero p { max-width: 560px; margin: 0; color: #b6bcc8; font-size: 15.5px; line-height: 1.75; }
|
||||
.about-hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
|
||||
.about-primary, .about-secondary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 44px; padding: 0 22px; border-radius: 999px; font-size: 14px; font-weight: 700; transition: transform .2s ease, box-shadow .2s ease, background .2s ease; }
|
||||
.about-primary { background: linear-gradient(120deg, #ffffff, #e8eeff); color: #0b0c10; box-shadow: 0 10px 30px #0008; }
|
||||
.about-primary:hover { transform: translateY(-2px); box-shadow: 0 16px 40px #000a; }
|
||||
.about-secondary { background: #ffffff10; color: #fff; backdrop-filter: blur(8px); }
|
||||
.about-secondary:hover { transform: translateY(-2px); background: #ffffff20; }
|
||||
.about-stores { display: flex; flex-direction: column; gap: 10px; margin-top: 30px; }
|
||||
.about-stores-label { color: #8b90a0; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.about-stores-row { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.about-store { display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: 14px; background: #ffffff0f; color: #fff; backdrop-filter: blur(8px); transition: background .2s ease, transform .2s ease; }
|
||||
.about-store:hover { background: #ffffff1f; transform: translateY(-2px); }
|
||||
.about-store > span { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.about-store small { color: #9298a6; font-size: 10px; }
|
||||
.about-store b { color: #fff; font-size: 13px; font-weight: 700; }
|
||||
.about-values { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 28px; }
|
||||
.about-values span { padding: 6px 12px; border-radius: 999px; background: #ffffff10; color: #c3c8d4; font-size: 12px; }
|
||||
.about-hero-visual { display: grid; place-items: center; min-height: 360px; }
|
||||
.about-phone { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; width: 200px; height: 350px; border-radius: 34px; background: linear-gradient(165deg, #123264, #14151b 58%, #360f1c); box-shadow: 0 0 0 8px #ffffff06, 0 46px 100px #000b, inset 0 1px 0 #ffffff26; animation: about-float-y 6.5s ease-in-out infinite; }
|
||||
.about-phone::before { content: ''; position: absolute; top: 13px; width: 48px; height: 4px; border-radius: 2px; background: #ffffff33; }
|
||||
.about-phone-glow { position: absolute; inset: 14px; border-radius: 24px; background: radial-gradient(circle at 50% 28%, #2f6bff38, transparent 62%); }
|
||||
.about-phone-logo { position: relative; color: #fff; font-size: 36px; font-weight: 800; letter-spacing: -.04em; }
|
||||
.about-phone-play { position: relative; display: grid; place-items: center; width: 58px; height: 58px; border-radius: 50%; background: linear-gradient(135deg, #ff2d55, #d1003a); color: #fff; box-shadow: 0 12px 34px #ff003355, inset 0 1px 0 #ffffff40; }
|
||||
.about-phone small { position: relative; color: #a9ccff; font-size: 10px; font-weight: 700; letter-spacing: .28em; }
|
||||
@keyframes about-float-y { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
|
||||
.about-float { position: absolute; z-index: 3; padding: 7px 12px; border-radius: 999px; background: #191a20e6; color: #e9edf5; font-size: 11px; white-space: nowrap; box-shadow: 0 12px 28px #0009; backdrop-filter: blur(10px); }
|
||||
.about-float-a { top: 22px; left: 4px; animation: about-float-y 7.2s ease-in-out infinite; }
|
||||
.about-float-b { top: 48%; right: -8px; animation: about-float-y 8s ease-in-out .6s infinite; }
|
||||
.about-float-c { bottom: 26px; left: 16px; animation: about-float-y 7.6s ease-in-out .3s infinite; }
|
||||
|
||||
/* 内容分区 */
|
||||
.about-section { position: relative; margin-top: 64px; }
|
||||
.about-section::before { counter-increment: about-section; content: '0' counter(about-section); position: absolute; top: -30px; left: -6px; color: #ffffff0d; font-size: 76px; font-weight: 800; line-height: 1; letter-spacing: -.05em; pointer-events: none; }
|
||||
.about-section > h2 { position: relative; margin: 0 0 14px; color: #fff; font-size: 27px; font-weight: 700; letter-spacing: -.01em; }
|
||||
.about-lead { position: relative; max-width: 760px; margin: 0; color: #b7bac3; font-size: 15.5px; line-height: 1.85; }
|
||||
.about-pillars { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; margin-top: 28px; }
|
||||
.about-pillar { position: relative; overflow: hidden; padding: 28px; border-radius: 20px; background: linear-gradient(180deg, #191a20, #101013); box-shadow: 0 20px 44px #0006; transition: transform .26s ease, box-shadow .26s ease; }
|
||||
.about-pillar::before { content: ''; position: absolute; top: -60px; right: -40px; width: 160px; height: 160px; border-radius: 50%; background: radial-gradient(circle, #2f6bff2e, transparent 70%); pointer-events: none; }
|
||||
.about-pillar:hover { transform: translateY(-5px); box-shadow: 0 32px 64px #000b; }
|
||||
.about-pillar-icon { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; background: linear-gradient(135deg, #1f6feb38, #ff2d5533); color: #c3dbff; }
|
||||
.about-pillar h3 { margin: 20px 0 8px; color: #fff; font-size: 17px; font-weight: 700; }
|
||||
.about-pillar p { margin: 0; color: #a4a8b3; font-size: 13px; line-height: 1.75; }
|
||||
.about-roadmap { position: relative; margin: 26px 0 0; padding: 0 0 0 26px; list-style: none; }
|
||||
.about-roadmap::before { content: ''; position: absolute; left: 17px; top: 10px; bottom: 18px; width: 1px; background: linear-gradient(180deg, #4d8dff66, #ff2d5544 55%, transparent); }
|
||||
.about-roadmap li { display: flex; gap: 18px; padding: 14px 0 20px; }
|
||||
.about-step-index { display: grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 50%; background: #0f0f13; box-shadow: 0 0 0 1px #ffffff1a, 0 0 20px #2f6bff2e; color: #9ecbff; font-size: 12px; font-weight: 700; }
|
||||
.about-roadmap b { display: block; margin-bottom: 5px; color: #f2f4f8; font-size: 15px; font-weight: 700; }
|
||||
.about-roadmap p { margin: 0; color: #a4a8b3; font-size: 13px; line-height: 1.75; }
|
||||
.about-cta { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 24px; overflow: hidden; margin-top: 64px; padding: 40px; border-radius: 26px; background: radial-gradient(120% 160% at 0% 0%, #16294a, #101014 52%, #2a0f18); box-shadow: 0 40px 100px #0007, inset 0 1px 0 #ffffff10; }
|
||||
.about-cta::before { content: ''; position: absolute; top: -160px; right: -80px; width: 380px; height: 380px; border-radius: 50%; background: radial-gradient(circle, #ff2d5533, transparent 65%); pointer-events: none; }
|
||||
.about-cta h2 { position: relative; margin: 0 0 8px; color: #fff; font-size: 26px; font-weight: 700; letter-spacing: -.015em; }
|
||||
.about-cta p { position: relative; margin: 0; max-width: 560px; color: #b6bac4; font-size: 14px; line-height: 1.75; }
|
||||
.about-cta .about-primary { position: relative; }
|
||||
|
||||
@media (max-width: 860px) { .about-hero { grid-template-columns: 1fr; padding: 40px 32px; } .about-hero-visual { display: none; } .about-hero h1 { font-size: 38px; } }
|
||||
|
||||
@media (max-width: 900px) { .short-rail { left: calc(100% + 8px); } }
|
||||
@media (min-width: 1700px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
@@ -391,7 +410,9 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.upload-backdrop { padding: 12px; }
|
||||
.upload-dialog { padding: 18px; }
|
||||
.about-page { padding: 4px 0 16px; }
|
||||
.about-hero { padding: 32px 22px; border-radius: 18px; }
|
||||
.about-hero { padding: 32px 22px; border-radius: 22px; }
|
||||
.about-section { margin-top: 44px; }
|
||||
.about-section::before { top: -20px; font-size: 52px; }
|
||||
.about-hero h1 { font-size: 32px; }
|
||||
.about-hero p { font-size: 14px; }
|
||||
.about-hero-actions { margin-top: 20px; }
|
||||
@@ -408,14 +429,19 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.short-slide-info h1 { font-size: 16px; }
|
||||
.shorts-state { top: 12px; }
|
||||
.watch-page { max-width: none; padding: 0; }
|
||||
.watch-player, .watch-skeleton-player { width: calc(100% + 24px); margin: 0 -12px; border-radius: 0; }
|
||||
.watch-title { font-size: 18px; margin: 14px 0 10px; }
|
||||
.watch-toolbar { gap: 12px; }
|
||||
.watch-stage { margin: 0 2px; padding: 16px 10px; border-radius: 18px; }
|
||||
.watch-player { height: min(62vh, 520px); border-radius: 14px; }
|
||||
.watch-skeleton-player { width: min(100%, 260px); }
|
||||
.watch-meta { margin: 16px 2px 8px; font-size: 12px; }
|
||||
.watch-title { font-size: 19px; margin-bottom: 14px; }
|
||||
.watch-toolbar { gap: 12px; padding: 12px 0; }
|
||||
.watch-channel { width: 100%; }
|
||||
.watch-actions { width: 100%; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
|
||||
.watch-actions::-webkit-scrollbar { display: none; }
|
||||
.watch-actions button { flex: none; }
|
||||
.watch-side { margin-top: 28px; }
|
||||
.watch-description { padding: 14px 16px; font-size: 13px; }
|
||||
.watch-side { position: static; margin-top: 24px; padding: 14px; border-radius: 16px; }
|
||||
.watch-episode-list { max-height: none; }
|
||||
.history-heading h1 { font-size: 20px; }
|
||||
.bottom-nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; display: flex; height: 64px; background: #151515; border-top: 1px solid #292929; }
|
||||
.bottom-nav a { flex: 1; display: grid; place-items: center; gap: 3px; color: #aaa; font-size: 10px; line-height: 1.2; }
|
||||
@@ -425,3 +451,86 @@ body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, He
|
||||
.sidebar.closed { display: none; }
|
||||
.scrim { position: fixed; inset: 0; z-index: 45; background: #0006; border: 0; }
|
||||
}
|
||||
|
||||
/* 路由懒加载占位 */
|
||||
.route-loading { min-height: 60vh; }
|
||||
|
||||
/* 自定义视频控件 */
|
||||
.vp { position: relative; display: block; width: 100%; height: 100%; overflow: hidden; background: #000; outline: none; }
|
||||
.vp .video-player { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
|
||||
.vp.is-playing:not(.show-controls) { cursor: none; }
|
||||
.vp-controls { position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; padding: 38px 14px 10px; background: linear-gradient(transparent, #000000bd 42%, #000000f2); opacity: 0; transform: translateY(10px); transition: opacity .24s ease, transform .28s cubic-bezier(.22, .61, .36, 1); pointer-events: none; }
|
||||
.vp.show-controls .vp-controls { opacity: 1; transform: none; pointer-events: auto; }
|
||||
.vp.show-controls .vp-progress { animation: vp-rise .34s cubic-bezier(.22, .61, .36, 1) both; }
|
||||
.vp.show-controls .vp-row { animation: vp-rise .38s cubic-bezier(.22, .61, .36, 1) .05s both; }
|
||||
@keyframes vp-rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
.vp-progress { position: relative; display: flex; align-items: center; height: 18px; cursor: pointer; touch-action: none; }
|
||||
.vp-progress-track { position: relative; width: 100%; height: 4px; overflow: hidden; border-radius: 999px; background: #ffffff24; box-shadow: inset 0 1px 2px #0006; transition: height .18s ease; }
|
||||
.vp-progress:hover .vp-progress-track { height: 6px; }
|
||||
.vp-buffered { position: absolute; top: 0; bottom: 0; left: 0; background: #ffffff38; transition: width .3s ease; }
|
||||
.vp-played { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 999px; background: linear-gradient(90deg, #ffffff, #ffd9e2); box-shadow: 0 0 12px #ffffff66; transition: width .12s linear; }
|
||||
.vp-thumb { position: absolute; top: 50%; width: 12px; height: 12px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 4px #ffffff1f, 0 0 16px #ffffff73; transform: translate(-50%, -50%) scale(0); transition: transform .2s cubic-bezier(.34, 1.56, .64, 1), box-shadow .2s ease; pointer-events: none; }
|
||||
.vp-progress:hover .vp-thumb, .vp-progress:active .vp-thumb { transform: translate(-50%, -50%) scale(1); }
|
||||
.vp-progress-tip { position: absolute; top: -30px; z-index: 5; padding: 3px 8px; border-radius: 7px; background: #0d0d0ff2; color: #fff; font-size: 11px; font-variant-numeric: tabular-nums; transform: translateX(-50%); pointer-events: none; box-shadow: 0 8px 20px #000a; backdrop-filter: blur(8px); animation: vp-tip-in .16s ease both; }
|
||||
@keyframes vp-tip-in { from { opacity: 0; transform: translate(-50%, 4px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
.vp-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; color: #fff; }
|
||||
.vp-btn { position: relative; display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 50%; background: transparent; color: #fff; cursor: pointer; transition: background .2s ease, box-shadow .2s ease, transform .16s cubic-bezier(.34, 1.56, .64, 1); }
|
||||
.vp-btn svg { transition: transform .18s ease; }
|
||||
.vp-btn:hover { background: #ffffff22; box-shadow: inset 0 0 0 1px #ffffff1a, 0 4px 14px #0006; }
|
||||
.vp-btn:hover svg { transform: scale(1.08); }
|
||||
.vp-btn:active { transform: scale(.9); }
|
||||
.vp-time { color: #d6d6d6; font-size: 12px; letter-spacing: .01em; font-variant-numeric: tabular-nums; }
|
||||
.vp-spacer { flex: 1; }
|
||||
.vp-volume { position: relative; display: flex; align-items: center; }
|
||||
.vp-volume-slider { position: absolute; left: 50%; bottom: 44px; z-index: 5; display: flex; align-items: center; width: 104px; height: 34px; padding: 0 12px; border-radius: 999px; background: #0d0d0ff2; box-shadow: 0 16px 36px #000c, inset 0 1px 0 #ffffff14; opacity: 0; visibility: hidden; transform: translate(-50%, 8px) scale(.94); transition: opacity .18s ease, transform .24s cubic-bezier(.34, 1.56, .64, 1), visibility .18s; backdrop-filter: blur(12px); }
|
||||
.vp-volume-slider.open { opacity: 1; visibility: visible; transform: translate(-50%, 0) scale(1); }
|
||||
.vp-volume-slider input { width: 100%; height: 3px; border-radius: 999px; background: #ffffff3d; outline: none; cursor: pointer; -webkit-appearance: none; appearance: none; }
|
||||
.vp-volume-slider input::-webkit-slider-thumb { width: 10px; height: 10px; border: 0; border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px #ffffff26; -webkit-appearance: none; appearance: none; }
|
||||
.vp-spinner { position: absolute; inset: 0; z-index: 2; width: 36px; height: 36px; margin: auto; border: 3px solid #ffffff26; border-top-color: #fff; border-radius: 50%; filter: drop-shadow(0 0 10px #ffffff40); animation: vp-spin .8s linear infinite; }
|
||||
@keyframes vp-spin { to { transform: rotate(360deg); } }
|
||||
.vp-big-play { position: absolute; inset: 0; z-index: 2; display: grid; place-items: center; width: 64px; height: 64px; margin: auto; border: 0; border-radius: 50%; background: #000000a6; color: #fff; backdrop-filter: blur(8px); cursor: pointer; transition: transform .22s cubic-bezier(.34, 1.56, .64, 1), background .2s ease, box-shadow .2s ease; animation: vp-pop .3s cubic-bezier(.34, 1.56, .64, 1) both; }
|
||||
.vp-big-play:hover { transform: scale(1.08); background: #000000cc; box-shadow: 0 0 0 8px #ffffff14, 0 16px 40px #000a; }
|
||||
.vp-big-play:active { transform: scale(.96); }
|
||||
@keyframes vp-pop { from { opacity: 0; transform: scale(.82); } to { opacity: 1; transform: scale(1); } }
|
||||
.vp-error { position: absolute; left: 50%; bottom: 54px; z-index: 4; max-width: 82%; padding: 10px 16px; border-radius: 999px; background: #241919ee; color: #ffd4cf; font-size: 12px; cursor: pointer; transform: translateX(-50%); backdrop-filter: blur(8px); }
|
||||
@media (max-width: 760px) {
|
||||
.vp-controls { padding: 30px 10px 8px; }
|
||||
.vp-row { gap: 4px; }
|
||||
.vp-btn { width: 36px; height: 36px; }
|
||||
.vp-big-play { width: 56px; height: 56px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vp-controls, .vp-btn, .vp-btn svg, .vp-thumb, .vp-played, .vp-buffered, .vp-big-play, .vp-volume-slider { transition: none; }
|
||||
.vp.show-controls .vp-progress, .vp.show-controls .vp-row, .vp-big-play, .vp-progress-tip { animation: none; }
|
||||
.about-phone, .about-float-a, .about-float-b, .about-float-c, .about-kicker::before { animation: none; }
|
||||
}
|
||||
|
||||
/* 点赞 / 收藏反馈特效 */
|
||||
@keyframes ymb-like-pop {
|
||||
0% { transform: scale(1); }
|
||||
30% { transform: scale(1.34) rotate(-7deg); }
|
||||
62% { transform: scale(.92) rotate(3deg); }
|
||||
100% { transform: scale(1) rotate(0); }
|
||||
}
|
||||
@keyframes ymb-like-ring {
|
||||
0% { opacity: .5; transform: scale(.96); }
|
||||
100% { opacity: 0; transform: scale(1.32); }
|
||||
}
|
||||
@keyframes ymb-like-count {
|
||||
0% { transform: translateY(0); }
|
||||
35% { transform: translateY(-5px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
.watch-actions button, .short-rail button { position: relative; }
|
||||
.watch-actions button.active svg,
|
||||
.short-rail button.active .rail-icon svg { animation: ymb-like-pop .46s cubic-bezier(.34, 1.56, .64, 1); }
|
||||
.watch-actions button.active > span,
|
||||
.short-rail button.active small { display: inline-block; animation: ymb-like-count .46s ease; }
|
||||
.watch-actions button.active::after { content: ''; position: absolute; inset: 0; border-radius: inherit; border: 1px solid #fff; pointer-events: none; animation: ymb-like-ring .5s ease-out forwards; }
|
||||
.short-rail .rail-icon { position: relative; }
|
||||
.short-rail button.active .rail-icon::after { content: ''; position: absolute; inset: -1px; border-radius: 50%; border: 1px solid #fff; pointer-events: none; animation: ymb-like-ring .5s ease-out forwards; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.watch-actions button.active svg, .short-rail button.active .rail-icon svg,
|
||||
.watch-actions button.active > span, .short-rail button.active small,
|
||||
.watch-actions button.active::after, .short-rail button.active .rail-icon::after { animation: none; }
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/* Author: fruit.com.cn */
|
||||
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.language-select select{border:1px solid #363636;border-radius:16px;background:#1d1d1d;color:#ddd;padding:6px 9px;font-size:11px}
|
||||
:root{--bg:#0f0f0f;--surface:#181818;--line:#303030;--text:#f1f1f1;--muted:#aaa;--accent:#ff4b3e;--sidebar:230px}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px Arial,sans-serif}a{color:inherit;text-decoration:none}button,input{font:inherit}.topbar{height:64px;position:sticky;top:0;z-index:3;display:flex;align-items:center;gap:24px;padding:0 28px;background:#0f0f0ff2;border-bottom:1px solid #222}.brand{display:flex;align-items:center;gap:8px;font-size:26px;font-weight:700;min-width:150px}.brand-symbol{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:var(--accent);font-size:12px}.search-form{display:flex;max-width:620px;flex:1;margin:auto}.search-form input{width:100%;height:40px;padding:0 16px;border:1px solid #3b3b3b;border-radius:22px 0 0 22px;background:#121212;color:#fff}.search-form button{width:54px;border:1px solid #3b3b3b;border-left:0;border-radius:0 22px 22px 0;background:#282828;color:#fff;font-size:24px}.top-actions{display:flex;align-items:center;gap:10px}.icon-button{border:0;background:transparent;color:#fff;font-size:22px;cursor:pointer}.profile-chip{border:0;border-radius:50%;width:32px;height:32px;background:#d45a68;color:#fff}.sidebar{position:fixed;top:64px;bottom:0;width:var(--sidebar);padding:20px 12px;background:var(--bg);border-right:1px solid #222}.main-nav,.genre-nav{display:grid;gap:4px}.nav-item,.genre-nav a{display:flex;gap:18px;align-items:center;padding:11px 14px;border-radius:10px;color:#ccc}.nav-item:hover,.nav-item.active,.genre-nav a:hover{background:#272727;color:#fff}.nav-item span{width:18px;text-align:center;font-size:20px}.nav-item b{font-weight:400}.nav-rule{height:1px;background:var(--line);margin:18px 10px}.nav-label{padding:0 14px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.sidebar-footer{position:absolute;bottom:24px;padding:0 14px;color:#666;font-size:11px;line-height:1.8}.content{max-width:1500px;padding:28px 34px 80px calc(var(--sidebar) + 34px);margin:auto}.hero-card{position:relative;min-height:350px;overflow:hidden;border-radius:18px;background:#27191a;margin-bottom:44px}.hero-copy{position:relative;z-index:1;width:52%;padding:54px}.eyebrow{color:#ff786f;font-size:11px;font-weight:700;letter-spacing:.16em}.hero-copy h1{margin:12px 0;font-size:56px;line-height:.98}.hero-copy h1 em{color:#ff786f;font-style:normal}.hero-copy p{max-width:390px;color:#ccc;line-height:1.7}.hero-art{position:absolute;inset:0 0 0 40%}.hero-art img{width:100%;height:100%;object-fit:cover}.hero-gradient{position:absolute;inset:0;background:linear-gradient(90deg,#27191a,rgba(39,25,26,.75) 26%,transparent 80%)}.hero-actions{display:flex;gap:10px}.primary-button,.ghost-button,.chip{border:0;border-radius:22px;padding:11px 17px;cursor:pointer;font-weight:600}.primary-button{background:#fff;color:#111}.ghost-button,.chip{background:#2b2b2b;color:#fff}.section-block{margin-bottom:46px}.section-heading{display:flex;justify-content:space-between;align-items:end;margin-bottom:17px}.section-heading h2{margin:7px 0 0;font-size:24px}.see-all{color:#ff8a82;font-size:13px}.chip-row{display:flex;gap:8px;overflow:auto;margin-bottom:18px}.chip.active,.chip:hover{background:#fff;color:#111}.video-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:24px 16px}.thumb{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:10px;background:#222}.thumb img,.short-card img{width:100%;height:100%;object-fit:cover}.duration{position:absolute;right:7px;bottom:7px;padding:3px 6px;background:#000c;border-radius:4px;font-size:11px}.video-meta{display:flex;gap:9px;padding-top:9px}.channel-avatar{display:grid;place-items:center;flex:none;width:32px;height:32px;border-radius:50%;background:#734c67;font-weight:700}.video-title{margin:0;line-height:1.4;font-weight:600}.video-sub{margin:4px 0 0;color:#999;font-size:11px;line-height:1.45}.shorts-row{display:grid;grid-template-columns:repeat(6,1fr);gap:14px}.short-card{position:relative;aspect-ratio:9/14;overflow:hidden;border-radius:11px}.short-card:after{content:'';position:absolute;inset:45% 0 0;background:linear-gradient(transparent,#000c)}.short-title{position:absolute;z-index:1;bottom:10px;left:9px;right:9px;font-size:12px;font-weight:600}.channel-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.channel-card{display:flex;align-items:center;gap:10px;padding:14px;border:1px solid var(--line);border-radius:11px}.channel-card p{margin:0 0 4px;font-weight:600}.channel-card span{color:#888;font-size:11px}.follow-button{margin-left:auto;border:0;border-radius:18px;padding:8px 11px;background:#fff;color:#111}.bottom-nav,.mobile-only{display:none}.toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%,20px);opacity:0;background:#fff;color:#111;padding:11px 16px;border-radius:7px;transition:.2s}.toast.show{opacity:1;transform:translate(-50%,0)}
|
||||
@media(max-width:1100px){.video-grid{grid-template-columns:repeat(3,1fr)}.shorts-row{grid-template-columns:repeat(5,1fr)}.channel-grid{grid-template-columns:repeat(2,1fr)}}
|
||||
@media(max-width:760px){.topbar{height:58px;padding:0 14px;gap:12px}.mobile-only{display:block}.desktop-only,.sidebar{display:none}.brand{min-width:auto;font-size:23px}.search-form{position:absolute;top:65px;left:14px;right:14px;display:none}.search-form.open{display:flex}.content{padding:20px 14px 90px}.hero-card{min-height:420px;margin:0 -14px 36px;border-radius:0;display:flex;align-items:flex-end}.hero-copy{width:100%;padding:24px}.hero-copy h1{font-size:42px}.hero-art{inset:0}.hero-gradient{background:linear-gradient(0deg,#27191a,rgba(39,25,26,.45) 55%,transparent)}.section-heading h2{font-size:21px}.video-grid{grid-template-columns:repeat(2,1fr);gap:22px 12px}.shorts-row{display:flex;overflow:auto;margin-right:-14px}.short-card{flex:0 0 130px}.channel-grid{grid-template-columns:1fr}.bottom-nav{position:fixed;display:flex;justify-content:space-around;align-items:center;left:0;right:0;bottom:0;height:66px;z-index:3;background:#151515;border-top:1px solid #292929}.bottom-nav a{display:grid;place-items:center;gap:4px;color:#aaa;font-size:10px}.bottom-nav span{font-size:20px}.bottom-nav .active{color:#fff}.bottom-nav .active span{color:var(--accent)}}
|
||||
|
Before Width: | Height: | Size: 971 KiB |