Compare commits

...
7 Commits
Author SHA1 Message Date
Zhou Weidong 9d9c7e3eec chore: 移除未使用的 BottomNav 组件 2026-11-12 15:14:18 +08:00
Zhou Weidong 2d1f754a46 feat: 移动端沉浸式 Shorts 与播放器/下载徽章体验优化
- 移动端移除底部导航,Shorts 视频改为圆角卡片并适配 iOS 裁剪
- 播放进度条全局贴到视频最底,移动端精简控件(仅播放/全屏)
- 右侧互动栏下移,首页进入播放页增加模糊封面过渡
- 关于页下载入口改为标准 App Store / Google Play 徽章
- 新增自动连播下一集与自动播放被拦截时的开启声音提示
2026-11-12 15:14:09 +08:00
Zhou Weidong 6fd936ac97 fix: 点赞收藏反馈动效不再被 reduce-motion 媒体查询禁用 2026-11-12 12:48:41 +08:00
Zhou Weidong ecfbd3b8b5 refactor: 企业级目录重构与播放/关于页体验升级
- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件
- 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分
- 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈
- 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口
- 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题
- 语言按本地偏好与 IP 识别,国旗改为内联 SVG
- 清理未使用的旧站点文件,补充 ESLint 配置
2026-11-12 12:32:29 +08:00
Zhou Weidong 8dd5283892 fix: 移动端点击侧边栏菜单项后自动收起抽屉 2026-11-12 10:32:24 +08:00
Zhou Weidong cef39e96c0 fix: 观看进度上报保留长整型剧集ID字符串,避免精度丢失导致记录写入失败 2026-11-11 18:17:23 +08:00
Zhou Weidong eced2890ae feat: complete YouTube-style site experience for YMB
- rebuild home feed with sticky server-driven categories and skeletons
- add full-screen shorts feed with likes, comments, saves and follows
- rebuild watch page with same-drama episode swiping and locked states
- wire search, watch history and episode stats to the app API
- add about page with app store links and an upload video dialog
- add SVG language flags, formatted view counts and site icons
2026-11-11 17:53:45 +08:00
102 changed files with 6001 additions and 501 deletions

No files matched your search

+16 -11
View File
@@ -8,24 +8,29 @@ 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
└── i18n/ # ID / EN / zh-CN 语言包
```
依赖方向:`features → entities → shared`,`layouts → shared`;`shared` 不允许反向依赖业务域。
## YouTube 对照的信息架构
- 顶部 AppBar:品牌、全局搜索、语言、通知、用户入口。
- 桌面侧栏:Home、Shorts、Subscriptions、Library、History、Liked videos。
- 首页:Hero 推荐、Topic chips、视频网格、Shorts 横滑、频道推荐。
- 观看页:16:9 播放器、标题/频道信息、Up next 推荐队列。
- 移动端:抽屉侧栏、底部四项导航、两列视频卡与横滑 Shorts。
- 移动端:抽屉侧栏、两列视频卡与横滑 Shorts(无底部导航,视频带圆角)。
## 运行
@@ -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`。
-2
View File
@@ -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()})();
-11
View File
@@ -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 上下滑、点赞/收藏/评论、语言切换、移动端侧栏、上传弹窗。
- 目录符合本文第二节,跨层依赖检查无违规。
+32
View File
@@ -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 } },
},
];
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

+3
View File
@@ -5,6 +5,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f0f0f" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="description" content="YMB — short stories, big emotions." />
<meta name="robots" content="index,follow,max-image-preview:large" />
<link rel="canonical" href="https://ymb.example.com/" />
+9 -1
View File
@@ -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"
}
}
+2245
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File
File renamed without changes.
View File
File renamed without changes.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

+12 -9
View File
@@ -1,11 +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 { 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={<ListingPage type="history" />} /><Route path="/search" element={<SearchPage />} /><Route path="/watch/:id" element={<WatchPage />} /></Route></Routes></StoreProvider></BrowserRouter>; }
export function App() {
return (
<BrowserRouter>
<AppProviders>
<AppRouter />
</AppProviders>
</BrowserRouter>
);
}
+90
View File
@@ -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;
}
+29
View File
@@ -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>
);
}
-9
View File
@@ -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); }
-10
View File
@@ -1,10 +0,0 @@
// Author: fruit.com.cn
import { Check, ChevronDown, Globe2 } from 'lucide-react';
import { useState } from 'react';
import { useAppStore } from '../app/store';
const languages = [{ code: 'id', label: 'Bahasa Indonesia', short: 'ID', flag: '🇮🇩' }, { code: 'en', label: 'English', short: 'EN', flag: '🇺🇸' }, { code: 'zh', label: '简体中文', short: '中文', flag: '🇨🇳' }];
export function LanguageSwitcher() {
const { lang, setLang } = useAppStore(); const [open, setOpen] = useState(false); const current = languages.find((item) => item.code === lang) || languages[0];
return <div className="language-switcher"><button className="language-trigger" onClick={() => setOpen(!open)} aria-expanded={open}><Globe2 size={16} /><span>{current.flag}</span><b>{current.short}</b><ChevronDown size={14} /></button>{open && <><button className="language-backdrop" onClick={() => setOpen(false)} aria-label="Close language menu" /><div className="language-menu">{languages.map((item) => <button key={item.code} className={item.code === lang ? 'selected' : ''} onClick={() => { setLang(item.code); setOpen(false); }}><span>{item.flag}</span><span>{item.label}</span>{item.code === lang && <Check size={16} />}</button>)}</div></>}</div>;
}
-8
View File
@@ -1,8 +0,0 @@
// Author: fruit.com.cn
import { MoreVertical, Play } from 'lucide-react';
import { Link } from 'react-router-dom';
export function MediaCard({ video, compact = false }) {
const image = video.image?.startsWith('/') || video.image?.startsWith('http') ? video.image : `/assets/${video.image}`;
return <article className={`media-card ${compact ? 'compact' : ''}`}><Link to={`/watch/${video.id}`} className="media-thumb"><img src={image} alt={video.title} loading="lazy" /><b>{video.duration}</b><span className="thumb-play"><Play size={16} fill="currentColor" /></span></Link>{compact ? <Link to={`/watch/${video.id}`} className="short-media-title">{video.title}</Link> : <div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div className="media-copy"><Link to={`/watch/${video.id}`} className="media-title">{video.title}</Link><small>{video.channel}<br />{video.views} · 2 hari lalu</small></div><button className="media-more" type="button" aria-label="More actions"><MoreVertical size={18} /></button></div>}</article>;
}
-2
View File
@@ -1,2 +0,0 @@
// Author: fruit.com.cn
export function SectionHeading({ title, action = 'Lihat semua →' }) { return <div className="section-heading"><h2>{title}</h2><button>{action}</button></div>; }
-11
View File
@@ -1,11 +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('');
useEffect(() => { const video = ref.current; if (!video || !src) return undefined; video.muted = muted; 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) => onProgress?.(event.currentTarget.currentTime)} />{!ready && !error && <button className="video-play-button" onClick={playNow}>▶</button>}{error && <button className="video-error-button" onClick={playNow}>{error} · 点击重试</button>}</div>;
}
+8
View File
@@ -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),
};
+11
View File
@@ -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),
};
+52
View File
@@ -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;
});
}
+119
View File
@@ -0,0 +1,119 @@
// Author: fruit.com.cn
import { ArrowRight, Clapperboard, Cpu, Play, Sparkles, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { aboutContent } from './content';
const APP_DOWNLOAD_URL = 'https://cdnetworksdist.okrivo.xyz/wPwBG';
const PILLARS = [
{ key: 'join', icon: Users },
{ key: 'produce', icon: Clapperboard },
{ key: 'compute', icon: Cpu },
];
function AppleLogo() {
return (
<svg width="22" height="24" viewBox="0 0 384 512" fill="currentColor" aria-hidden="true">
<path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
);
}
function GooglePlayLogo() {
return (
<svg width="22" height="24" viewBox="0 0 512 512" aria-hidden="true">
<path fill="#00A0FF" d="M64 40v432l248-216z" />
<path fill="#00E676" d="M64 40l248 216 80-80z" />
<path fill="#FF3A44" d="M64 472l248-216 80 80z" />
<path fill="#FFCE00" d="M312 256l80-80 56 56c14 14 14 34 0 48l-56 56z" />
</svg>
);
}
export function AboutPage() {
const { locale } = useLocale();
const copy = aboutContent[locale.code] || aboutContent.en;
return (
<div className="about-page">
<section className="about-hero">
<div className="about-hero-copy">
<span className="about-kicker">{copy.kicker}</span>
<h1>{copy.heroTitle}</h1>
<p>{copy.heroText}</p>
<div className="about-hero-actions">
<Link to="/" className="about-primary"><Sparkles size={16} />{copy.explore}</Link>
<Link to="/shorts" className="about-secondary"><Play size={16} fill="currentColor" />{copy.watchShorts}</Link>
</div>
<div className="about-stores">
<span className="about-stores-label">{copy.downloadLabel}</span>
<div className="about-stores-row">
<a className="store-badge" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<AppleLogo />
<span className="store-badge-copy"><small>{copy.iosSmall}</small><b>App Store</b></span>
</a>
<a className="store-badge" href={APP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
<GooglePlayLogo />
<span className="store-badge-copy"><small>{copy.androidSmall}</small><b>Google Play</b></span>
</a>
</div>
</div>
<div className="about-values">{copy.values.map((value) => <span key={value}>{value}</span>)}</div>
</div>
<div className="about-hero-visual" aria-hidden="true">
<span className="about-float about-float-a">{copy.joinTitle}</span>
<span className="about-float about-float-b">{copy.produceTitle}</span>
<span className="about-float about-float-c">{copy.computeTitle}</span>
<div className="about-phone">
<span className="about-phone-glow" />
<span className="about-phone-logo">ymb</span>
<span className="about-phone-play"><Play size={26} fill="currentColor" /></span>
<small>SHORTS</small>
</div>
</div>
</section>
<section className="about-section">
<h2>{copy.whoTitle}</h2>
<p className="about-lead">{copy.whoText}</p>
</section>
<section className="about-section">
<h2>{copy.pillarsTitle}</h2>
<div className="about-pillars">
{PILLARS.map(({ key, icon: Icon }) => (
<article className="about-pillar" key={key}>
<span className="about-pillar-icon"><Icon size={22} /></span>
<h3>{copy[`${key}Title`]}</h3>
<p>{copy[`${key}Text`]}</p>
</article>
))}
</div>
</section>
<section className="about-section">
<h2>{copy.roadmapTitle}</h2>
<ol className="about-roadmap">
{copy.roadmap.map((step, index) => (
<li key={step.title}>
<span className="about-step-index">{String(index + 1).padStart(2, '0')}</span>
<div>
<b>{step.year} · {step.title}</b>
<p>{step.text}</p>
</div>
</li>
))}
</ol>
</section>
<section className="about-cta">
<div>
<h2>{copy.ctaTitle}</h2>
<p>{copy.ctaText}</p>
</div>
<Link to="/shorts" className="about-primary">{copy.ctaButton}<ArrowRight size={16} /></Link>
</section>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
// Author: fruit.com.cn
export const aboutContent = {
en: {
kicker: 'OFFICIAL YMB SITE',
heroTitle: 'Short stories. Big business.',
heroText: 'YMB (Your Mini Business) is the official home of the YMB micro-drama app — a mobile-first stage for bite-sized stories, and an open platform for the next generation of creators.',
explore: 'Explore dramas',
watchShorts: 'Watch Shorts',
downloadLabel: 'Get the YMB app',
iosSmall: 'Download on the',
androidSmall: 'Get it on',
values: ['Mobile-first', 'Multilingual', 'Open platform'],
whoTitle: 'Who we are',
whoText: 'YMB brings vertical, fast-paced dramas to audiences around the world. We localize stories across languages, connect them with the right viewers, and keep the experience light, mobile-first, and free to explore.',
pillarsTitle: 'Where YMB is going',
joinTitle: 'Creator onboarding',
joinText: 'Studios and independent creators will be able to join YMB, publish their work, and grow with the platform.',
produceTitle: 'Self-production',
produceText: 'Original YMB productions get the tools, distribution, and audience to turn a good story into a hit series.',
computeTitle: 'Computing power',
computeText: 'A future marketplace for rendering and AI compute — buy, sell, and allocate capacity for production.',
roadmapTitle: 'Roadmap',
roadmap: [
{ year: '2026', title: 'App launch', text: 'The YMB app goes live with a multilingual micro-drama library and a swipe-first Shorts feed.' },
{ year: 'Next', title: 'Creator onboarding', text: 'Open registration for creators and studios, with publishing and audience tools.' },
{ year: 'Next', title: 'Self-production', text: 'YMB Originals and partner productions, supported from script to screen.' },
{ year: 'Next', title: 'Compute marketplace', text: 'Sell or allocate rendering and AI compute directly inside the YMB ecosystem.' },
],
ctaTitle: 'Build the next hit with YMB.',
ctaText: 'Whether you create, produce, or power the next story — there is a place for you on YMB.',
ctaButton: 'Start exploring',
},
id: {
kicker: 'SITUS RESMI YMB',
heroTitle: 'Cerita pendek. Bisnis besar.',
heroText: 'YMB (Your Mini Business) adalah rumah resmi aplikasi drama pendek YMB — panggung mobile-first untuk cerita singkat, sekaligus platform terbuka bagi generasi kreator berikutnya.',
explore: 'Jelajahi drama',
watchShorts: 'Tonton Shorts',
downloadLabel: 'Unduh aplikasi YMB',
iosSmall: 'Unduh di',
androidSmall: 'Dapatkan di',
values: ['Mobile-first', 'Multibahasa', 'Platform terbuka'],
whoTitle: 'Siapa kami',
whoText: 'YMB menghadirkan drama vertikal yang cepat ke penonton di seluruh dunia. Kami melokalkan cerita, menghubungkannya dengan penonton yang tepat, dan menjaga pengalaman tetap ringan, mobile-first, dan gratis untuk dijelajahi.',
pillarsTitle: 'Arah YMB berikutnya',
joinTitle: 'Bergabung kreator',
joinText: 'Studio dan kreator independen akan dapat bergabung dengan YMB, mempublikasikan karya, dan tumbuh bersama platform.',
produceTitle: 'Produksi mandiri',
produceText: 'Produksi orisinal YMB mendapatkan alat, distribusi, dan audiens untuk mengubah cerita bagus menjadi serial hit.',
computeTitle: 'Daya komputasi',
computeText: 'Marketplace masa depan untuk rendering dan komputasi AI — beli, jual, dan alokasikan kapasitas produksi.',
roadmapTitle: 'Peta jalan',
roadmap: [
{ year: '2026', title: 'Peluncuran app', text: 'Aplikasi YMB hadir dengan pustaka drama multibahasa dan feed Shorts geser.' },
{ year: 'Berikutnya', title: 'Bergabung kreator', text: 'Pendaftaran terbuka untuk kreator dan studio, dengan alat publikasi dan audiens.' },
{ year: 'Berikutnya', title: 'Produksi mandiri', text: 'Produksi YMB Originals dan mitra, didukung dari naskah hingga layar.' },
{ year: 'Berikutnya', title: 'Marketplace komputasi', text: 'Jual atau alokasikan rendering dan komputasi AI langsung di ekosistem YMB.' },
],
ctaTitle: 'Bangun hit berikutnya bersama YMB.',
ctaText: 'Baik kamu kreator, produser, atau mitra komputasi — ada tempat untukmu di YMB.',
ctaButton: 'Mulai jelajahi',
},
zh: {
kicker: 'YMB 官方网站',
heroTitle: '短故事,大生意。',
heroText: 'YMB(Your Mini Business)微短剧 App 官网 —— 一个为竖屏短故事而生的移动舞台,也是一个向新一代创作者开放的平台。',
explore: '浏览短剧',
watchShorts: '观看 Shorts',
downloadLabel: '下载 YMB App',
iosSmall: '前往下载',
androidSmall: '前往获取',
values: ['移动优先', '多语言', '开放平台'],
whoTitle: '我们是谁',
whoText: 'YMB 把节奏明快、适合竖屏观看的短剧带给全球观众。我们做多语言本地化、内容分发与轻量化的移动观影体验,让好故事被更多人看到。',
pillarsTitle: 'YMB 的下一步',
joinTitle: '创作者入驻',
joinText: '影视公司与独立创作者将可以入驻 YMB,发布作品,并与平台一起成长。',
produceTitle: '自主制片',
produceText: 'YMB 自制内容将获得工具、分发与观众资源,把好故事做成爆款短剧。',
computeTitle: '算力出售',
computeText: '面向渲染与 AI 算力的未来市场,在 YMB 生态内买卖与调配算力。',
roadmapTitle: '发展路线',
roadmap: [
{ year: '2026', title: 'App 上线', text: 'YMB App 正式上线,提供多语言短剧库与竖滑 Shorts 信息流。' },
{ year: '下一步', title: '创作者入驻', text: '面向创作者与影视公司开放注册,提供发布与受众运营工具。' },
{ year: '下一步', title: '自主制片', text: 'YMB 自制与联合制片项目,从剧本到成片全程支持。' },
{ year: '下一步', title: '算力市场', text: '在 YMB 生态内直接出售或调配渲染与 AI 算力。' },
],
ctaTitle: '和 YMB 一起打造下一个爆款。',
ctaText: '无论你是创作者、制片方还是算力伙伴,YMB 都有你的位置。',
ctaButton: '开始探索',
},
};
+65
View File
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { HistoryGridSkeleton } from '../../shared/components/skeletons';
import { useMutation } from '../../shared/hooks/useMutation';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { watchApi } from '../../shared/api/watch';
import { normalizeProgress } from '../../entities/drama/model';
function episodeLabel(record, localeCode) {
if (!record.episodeNumber) return '';
return localeCode === 'zh' ? `第${record.episodeNumber}集` : `Episode ${record.episodeNumber}`;
}
export function HistoryPage() {
const { locale } = useLocale();
const history = useRequest(
({ signal }) => watchApi.recent(1, 20, { signal }).then((payload) => normalizePage(payload).list.map((item) => normalizeProgress(item, locale.code))),
[locale.code],
);
const clearHistory = useMutation(() => watchApi.clear());
const records = history.data;
const errorMessage = history.error?.message || clearHistory.error?.message || '';
const clearAll = async () => {
if (!window.confirm(`${locale.clearHistory}?`)) return;
try {
await clearHistory.run();
history.refetch();
} catch {
// 错误由 clearHistory.error 呈现
}
};
return (
<section className="history-page">
<div className="history-heading">
<h1>{locale.history}</h1>
{!!records?.length && <button type="button" onClick={clearAll}>{locale.clearHistory}</button>}
</div>
{history.loading && records === null && <HistoryGridSkeleton count={6} label={locale.loading} />}
{errorMessage && <div className="api-note warning">{errorMessage}</div>}
{records && !records.length && !errorMessage && <div className="api-note">{locale.historyEmpty}</div>}
{!!records?.length && (
<div className="video-grid">
{records.map((record) => {
const percent = Math.round(record.fraction * 100);
const meta = [episodeLabel(record, locale.code), record.durationMs > 0 ? `${percent}%` : ''].filter(Boolean).join(' · ');
return (
<article className="history-card" key={record.id}>
<Link to={`/watch/${record.id}`} className="history-thumb">
<img src={record.image} alt={record.title} loading="lazy" />
{record.durationMs > 0 && <span className="history-progress"><i style={{ width: `${percent}%` }} /></span>}
</Link>
<Link to={`/watch/${record.id}`} className="history-title">{record.title}</Link>
{meta && <small>{meta}</small>}
</article>
);
})}
</div>
)}
</section>
);
}
+129
View File
@@ -0,0 +1,129 @@
// Author: fruit.com.cn
import { useMemo, useState } from 'react';
import { useLocale } from '../../app/providers/AppProviders';
import { AsyncBoundary } from '../../shared/components/AsyncBoundary';
import { MediaCard } from '../../shared/components/MediaCard';
import { VideoGrid } from '../../shared/components/VideoGrid';
import { CategoryChipsSkeleton, ShortsShelfSkeleton, VideoGridSkeleton } from '../../shared/components/skeletons';
import { videos } from '../../shared/data/catalog';
import { searchCatalog } from '../../shared/data/catalogService';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { dramaApi } from '../../entities/drama/api';
import { categoryApi } from '../../entities/category/api';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { CategoryBar } from './components/CategoryBar';
const SHORTS_LIMIT = 5;
const FIRST_SHELF_LIMIT = 6;
const ALL_CATEGORY_ID = 'all';
const FALLBACK_GENRE_PREFIX = 'genre-';
function readTitle(locales, localeCode) {
return locales?.[localeCode] || locales?.en || locales?.id || locales?.zh || '';
}
function normalizeHomeItems(payload, localeCode) {
const sections = payload?.sections || [];
return uniqueDramas(sections.flatMap((section) => section.dramas || []).map((item) => normalizeDrama(item, localeCode)));
}
function normalizeCategoryItems(payload, localeCode) {
return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)));
}
function normalizeCategories(payload, localeCode) {
return (Array.isArray(payload) ? payload : [])
.map((item) => ({
id: String(item.id ?? ''),
label: readTitle(item.locales, localeCode) || item.code || '',
}))
.filter((item) => item.id && item.label);
}
function fallbackGenreIndex(categoryId) {
const matched = /^genre-(\d+)$/.exec(categoryId);
return matched ? Number(matched[1]) : 0;
}
export function HomePage() {
const { locale } = useLocale();
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_ID);
const isAllCategory = activeCategory === ALL_CATEGORY_ID;
const feedRequest = useRequest(
({ signal }) => dramaApi.homeFeed({ signal }).then((payload) => normalizeHomeItems(payload, locale.code)),
[locale.code],
);
const categoriesRequest = useRequest(
({ signal }) => categoryApi.list({ signal }).then((payload) => normalizeCategories(payload, locale.code)),
[locale.code],
);
const categoryRequest = useRequest(
({ signal }) => categoryApi.dramas(activeCategory, 1, 20, { signal }).then((payload) => normalizeCategoryItems(payload, locale.code)),
[activeCategory, locale.code],
{ enabled: !isAllCategory },
);
const homeItems = feedRequest.data || [];
const serverCategories = useMemo(() => categoriesRequest.data || [], [categoriesRequest.data]);
const fallbackCategories = useMemo(
() => locale.genres.slice(1).map((label, index) => ({ id: `${FALLBACK_GENRE_PREFIX}${index + 1}`, label })),
[locale.genres],
);
const categories = useMemo(() => {
const allCategory = { id: ALL_CATEGORY_ID, label: locale.genres[0] };
if (serverCategories.length) return [allCategory, ...serverCategories];
return categoriesRequest.loading ? [allCategory] : [allCategory, ...fallbackCategories];
}, [categoriesRequest.loading, fallbackCategories, locale.genres, serverCategories]);
const fallbackItems = useMemo(() => {
const genreIndex = fallbackGenreIndex(activeCategory);
return searchCatalog('', locale.genres[genreIndex], genreIndex);
}, [activeCategory, locale.genres]);
const loading = isAllCategory ? feedRequest.loading : categoryRequest.loading;
const error = isAllCategory ? feedRequest.error : categoryRequest.error;
const remoteItems = isAllCategory ? homeItems : (categoryRequest.data || []);
const items = error ? fallbackItems : remoteItems;
const shortsItems = (homeItems.length ? homeItems : videos).slice(0, SHORTS_LIMIT);
const firstShelf = items.slice(0, FIRST_SHELF_LIMIT);
const secondShelf = items.slice(FIRST_SHELF_LIMIT);
return (
<>
{categoriesRequest.loading && !serverCategories.length ? <CategoryChipsSkeleton /> : (
<CategoryBar categories={categories} activeId={activeCategory} onChange={setActiveCategory} />
)}
<section className="youtube-home-feed" aria-label={locale.home}>
{error && <div className="api-note warning">{locale.apiFallback}</div>}
<AsyncBoundary
loading={loading && !items.length}
skeleton={<VideoGridSkeleton count={FIRST_SHELF_LIMIT} label={locale.loading} />}
empty={!loading && !error && !items.length}
emptyMessage={locale.empty}
>
<VideoGrid items={firstShelf} />
</AsyncBoundary>
</section>
<section className="youtube-shorts-shelf" aria-labelledby="shorts-title">
<div className="youtube-shelf-heading">
<h2 id="shorts-title"><span className="shorts-mark">▶</span>{locale.shortTitle}</h2>
<button type="button">{locale.explore}</button>
</div>
{feedRequest.loading && !homeItems.length ? <ShortsShelfSkeleton count={SHORTS_LIMIT} label={locale.loading} /> : (
<div className="shorts-row" aria-label={locale.shortTitle}>
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact to={`/shorts?start=${video.id}`} />)}
</div>
)}
</section>
{secondShelf.length > 0 && (
<section className="youtube-home-feed youtube-home-feed-secondary" aria-label={locale.picks}>
<VideoGrid items={secondShelf} />
</section>
)}
</>
);
}
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
export function CategoryBar({ categories, activeId, onChange }) {
return (
<div className="youtube-category-bar" role="tablist" aria-label="Categories">
{categories.map((category) => (
<button
className={`youtube-category-chip ${activeId === category.id ? 'active' : ''}`}
key={category.id}
type="button"
role="tab"
aria-selected={activeId === category.id}
onClick={() => onChange(category.id)}
>
{category.label}
</button>
))}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
import { useLocale } from '../../app/providers/AppProviders';
import { SectionHeading } from '../../shared/components/SectionHeading';
import { VideoGrid } from '../../shared/components/VideoGrid';
import { VideoGridSkeleton } from '../../shared/components/skeletons';
import { videos } from '../../shared/data/catalog';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { listingApi } from './api';
export function ListingPage({ type }) {
const { locale } = useLocale();
const title = type === 'shorts' ? locale.shortTitle : type === 'subscriptions' ? locale.channels : type === 'history' ? locale.history : locale.library;
const request = useRequest(
({ signal }) => listingApi.dramas({}, { signal }).then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))),
[locale.code],
);
const items = request.data?.length ? request.data : videos;
return (
<>
<SectionHeading title={title} />
{request.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
<>
{request.error && <div className="api-note warning">{locale.apiFallback}</div>}
<VideoGrid items={items} />
</>
)}
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Author: fruit.com.cn
import { http } from '../../shared/api';
export const listingApi = {
dramas: (body = {}, options) => http.post('/app/dramas/page', {
pageIndex: 1,
pageSize: 20,
categoryIds: [],
classificationIds: [],
tagIds: [],
...body,
}, options),
};
+49
View File
@@ -0,0 +1,49 @@
// Author: fruit.com.cn
import { Search as SearchIcon } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { SectionHeading } from '../../shared/components/SectionHeading';
import { VideoGrid } from '../../shared/components/VideoGrid';
import { VideoGridSkeleton } from '../../shared/components/skeletons';
import { useRequest } from '../../shared/hooks/useRequest';
import { normalizePage } from '../../shared/lib/pagination';
import { dramaApi } from '../../entities/drama/api';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { searchApi } from './api';
async function loadSearchResults(keyword, localeCode, signal) {
if (keyword) {
const payload = await searchApi.search(keyword, { signal });
return normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode));
}
const payload = await dramaApi.homeFeed({ signal });
const items = payload?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
}
export function SearchPage() {
const { locale } = useLocale();
const [params] = useSearchParams();
const query = (params.get('q') || '').trim();
const request = useRequest(({ signal }) => loadSearchResults(query, locale.code, signal), [query, locale.code]);
const results = request.data || [];
return (
<>
<SectionHeading title={query ? `${locale.searchResults} “${query}”` : locale.picks} />
{request.loading ? <VideoGridSkeleton count={6} label={locale.loading} /> : (
<>
{request.error && <div className="api-note warning">{locale.apiFallback}</div>}
{!request.error && query && results.length === 0 ? (
<div className="search-empty">
<SearchIcon size={28} />
<b>{locale.noResults} “{query}”</b>
</div>
) : (
<VideoGrid items={results} />
)}
</>
)}
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
// Author: fruit.com.cn
import { http } from '../../shared/api';
import { toQuery } from '../../shared/api/query';
export const searchApi = {
search: (keyword, options) => http.get(`/app/search${toQuery({ keyword, pageIndex: 1, pageSize: 48 })}`, options),
};
+156
View File
@@ -0,0 +1,156 @@
// Author: fruit.com.cn
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLocale, useUi } from '../../app/providers/AppProviders';
import { CommentsSheet } from '../../shared/components/CommentsSheet';
import { ShortsSkeleton } from '../../shared/components/skeletons';
import { useFollowedChannels } from '../../shared/hooks/useFollowedChannels';
import { useToast } from '../../shared/hooks/useToast';
import { normalizePage } from '../../shared/lib/pagination';
import { dramaApi } from '../../entities/drama/api';
import { normalizeDrama, uniqueDramas } from '../../entities/drama/model';
import { ShortSlide } from './components/ShortSlide';
const PAGE_SIZE = 6;
const PRELOAD_THRESHOLD = 2;
export function ShortsFeedPage() {
const { locale } = useLocale();
const { sidebarOpen } = useUi();
const [params] = useSearchParams();
const startId = params.get('start');
const [items, setItems] = useState([]);
const [activeId, setActiveId] = useState('');
const [status, setStatus] = useState({ loading: true, error: '', hasMore: true });
const { channels: followedChannels, toggle: toggleFollow } = useFollowedChannels();
const [commentsVideo, setCommentsVideo] = useState(null);
const toast = useToast();
const feedRef = useRef(null);
const cursorRef = useRef(null);
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const loadPage = useCallback(async (reset) => {
if (loadingRef.current && !reset) return;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
loadingRef.current = true;
setStatus((prev) => ({ ...prev, loading: true, error: '' }));
try {
const payload = await dramaApi.feed({ cursor: reset ? null : cursorRef.current, size: PAGE_SIZE });
if (requestId !== requestIdRef.current) return;
const list = uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)));
cursorRef.current = payload?.nextCursor ?? null;
setItems((prev) => (reset ? list : uniqueDramas([...prev, ...list])));
setStatus({ loading: false, error: '', hasMore: Boolean(payload?.hasMore) });
} catch (error) {
if (requestId !== requestIdRef.current) return;
setStatus((prev) => ({ ...prev, loading: false, error: error.message || 'Request failed' }));
} finally {
if (requestId === requestIdRef.current) loadingRef.current = false;
}
}, [locale.code]);
useEffect(() => {
cursorRef.current = null;
setItems([]);
setActiveId('');
setStatus({ loading: true, error: '', hasMore: true });
loadPage(true);
}, [loadPage]);
useEffect(() => {
if (!items.length) return;
if (items.some((item) => item.id === activeId)) return;
const startIndex = startId ? items.findIndex((item) => item.id === startId) : -1;
const nextIndex = startIndex >= 0 ? startIndex : 0;
const node = feedRef.current;
if (node && nextIndex > 0) node.scrollTop = nextIndex * node.clientHeight;
setActiveId(items[nextIndex].id);
}, [activeId, items, startId]);
useEffect(() => {
const node = feedRef.current;
if (!node) return undefined;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) setActiveId(entry.target.dataset.id);
});
}, { root: node, threshold: 0.7 });
node.querySelectorAll('[data-id]').forEach((slide) => observer.observe(slide));
return () => observer.disconnect();
}, [items.length]);
useEffect(() => {
if (!status.hasMore || status.loading || !activeId) return;
const index = items.findIndex((item) => item.id === activeId);
if (index >= items.length - PRELOAD_THRESHOLD) loadPage(false);
}, [activeId, items, loadPage, status.hasMore, status.loading]);
const activeIndex = Math.max(0, items.findIndex((item) => item.id === activeId));
const move = (delta) => {
const node = feedRef.current;
if (!node) return;
const next = Math.min(items.length - 1, Math.max(0, activeIndex + delta));
if (next !== activeIndex) node.scrollTo({ top: next * node.clientHeight, behavior: 'smooth' });
};
const shareVideo = async (video) => {
const url = `${window.location.origin}/watch/${video.id}`;
if (navigator.share) {
try {
await navigator.share({ title: video.title, url });
return;
} catch (error) {
if (error?.name === 'AbortError') return;
}
}
try {
await navigator.clipboard.writeText(url);
toast.show(locale.linkCopied);
} catch {
toast.show(url);
}
};
useEffect(() => {
const onKeyDown = (event) => {
if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); }
if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); }
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex, items.length]);
return (
<>
<section className={`shorts-stage ${sidebarOpen ? '' : 'shorts-stage-wide'}`}>
{status.loading && !items.length && <ShortsSkeleton label={locale.loading} />}
{status.error && <div className="shorts-state warning">{status.error}</div>}
{!status.loading && !status.error && !items.length && <div className="shorts-state">{locale.empty}</div>}
<div className="shorts-scroll" ref={feedRef} aria-label={locale.shortTitle}>
{items.map((video) => (
<ShortSlide
key={video.id}
video={video}
active={video.id === activeId}
followed={followedChannels.includes(video.channel)}
onToggleFollow={() => toggleFollow(video.channel)}
onOpenComments={() => setCommentsVideo(video)}
onShare={() => shareVideo(video)}
/>
))}
</div>
{items.length > 1 && (
<div className="feed-arrows">
<button onClick={() => move(-1)} disabled={!activeIndex} aria-label="Previous video" type="button">↑</button>
<button onClick={() => move(1)} disabled={activeIndex >= items.length - 1} aria-label="Next video" type="button">↓</button>
</div>
)}
</section>
{commentsVideo && <CommentsSheet video={commentsVideo} locale={locale} onClose={() => setCommentsVideo(null)} />}
{toast.message && <div className="shorts-toast">{toast.message}</div>}
</>
);
}
@@ -0,0 +1,62 @@
// Author: fruit.com.cn
import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
import { useLocale } from '../../../app/providers/AppProviders';
import { VideoPlayer } from '../../../shared/components/player/VideoPlayer';
import { useEngagement } from '../../../shared/hooks/useEngagement';
import { formatCount } from '../../../shared/lib/format';
import { reportWatchProgress } from '../../../shared/lib/watchProgress';
export function ShortSlide({ video, active, followed, onToggleFollow, onOpenComments, onShare }) {
const { locale } = useLocale();
const src = video.preview?.playUrl;
const likeTargetId = video.preview?.id || video.id;
const { liked, likeCount, saved, saveCount, toggleLike, toggleSave } = useEngagement({
likeTargetType: video.preview?.id ? 'EPISODE' : 'DRAMA',
likeTargetId,
dramaId: video.id,
initialLikeCount: video.likeCount,
initialSaveCount: video.favoriteCount,
});
return (
<article className="short-slide" data-id={video.id}>
<div className="short-slide-stage">
<div className="short-slide-info">
<div className="short-channel">
<span className="short-avatar">{video.channel?.[0] || 'Y'}</span>
<span className="short-channel-name">{video.channel}</span>
<button className={`short-follow ${followed ? 'followed' : ''}`} onClick={onToggleFollow} type="button">{followed ? locale.subscribed : locale.subscribe}</button>
</div>
<h1>{video.title}</h1>
</div>
<div className="short-column">
<div className="short-player-wrap">
<div className="short-slide-player">
{src
? <VideoPlayer src={src} poster={video.image} autoPlay={active} soundHint={locale.soundHint} 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>
);
}
+234
View File
@@ -0,0 +1,234 @@
// Author: fruit.com.cn
import { useCallback, 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 { HeroTransition } from '../../shared/components/HeroTransition';
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 { getHeroTransition } from '../../shared/lib/heroTransition';
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 });
const endedTimerRef = useRef(null);
useEffect(() => () => {
if (endedTimerRef.current) window.clearTimeout(endedTimerRef.current);
}, []);
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;
if (endedTimerRef.current) window.clearTimeout(endedTimerRef.current);
setEpisodeId(episode.id);
if (scroll) playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const goNext = () => changeEpisode(nextEpisode);
const goPrevious = () => changeEpisode(previousEpisode);
const handleEnded = () => {
if (!nextEpisode || nextEpisode.locked) return;
endedTimerRef.current = window.setTimeout(() => changeEpisode(nextEpisode), 320);
};
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 soundHint={locale.soundHint} onEnded={handleEnded} 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 [hero, setHero] = useState(() => getHeroTransition());
const clearHero = useCallback(() => setHero(null), []);
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]);
return (
<>
<HeroTransition data={hero} onDone={clearHero} />
{!drama.data && drama.loading && <WatchPageSkeleton label={locale.loading} />}
{!drama.data && !drama.loading && <div className="api-note warning">{drama.error?.message || locale.empty}</div>}
{drama.data && <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>
);
}
-13
View File
@@ -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;
}
+3 -3
View File
@@ -1,7 +1,7 @@
// Author: fruit.com.cn
export const locales = {
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', voiceSearch: 'Cari dengan suara', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], homeCategories: ['Semua', 'Podcast', 'Musik', 'Game', 'Live', 'Travel', 'Baru diupload', 'Temukan video baru'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', voiceSearch: 'Search with your voice', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], homeCategories: ['All', 'Podcasts', 'Music', 'Gaming', 'Live', 'Travel', 'Recently uploaded', 'New to you'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', voiceSearch: '通过语音搜索内容', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], homeCategories: ['全部', '播客', '音乐', '游戏', '直播', '旅游', '最近上传', '发现新视频'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
id: { code: 'id', htmlLang: 'id', title: 'YMB · Cerita pendek, emosi besar', description: 'Temukan drama pendek terbaik dan episode baru setiap hari.', search: 'Cari drama, aktor, atau genre', searchResults: 'Hasil pencarian untuk', noResults: 'Tidak ada hasil untuk', clearSearch: 'Hapus pencarian', create: 'Buat', notifications: 'Notifikasi', account: 'Akun', home: 'Beranda', shorts: 'Shorts', subscriptions: 'Langganan', library: 'Perpustakaan', history: 'Riwayat', clearHistory: 'Hapus semua', historyEmpty: 'Belum ada riwayat tontonan.', comments: 'Komentar', noComments: 'Belum ada komentar.', commentPlaceholder: 'Tambah komentar…', send: 'Kirim', share: 'Bagikan', save: 'Simpan', like: 'Suka', linkCopied: 'Tautan disalin', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Unggah video', uploadHint: 'Seret file video ke sini, atau klik untuk memilih', uploadHintSub: 'MP4, MOV atau WebM', uploadTitleLabel: 'Judul', uploadTitlePlaceholder: 'Beri judul untuk videomu', uploadCancel: 'Batal', uploadSubmit: 'Unggah', uploadSuccessTitle: 'Video dikirim untuk ditinjau', uploadSuccessText: 'Setelah disetujui, video akan tampil di channel-mu.', uploadDone: 'Selesai', close: 'Tutup', episodes: 'Episode', episodeSwipeHint: 'Geser atas/bawah untuk ganti episode', lockedEpisode: 'Masuk untuk membuka episode ini.', soundHint: 'Ketuk untuk suara', about: 'Tentang YMB', copyright: 'YMB · Your Mini Business. Hak cipta dilindungi.', you: 'Anda', playlists: 'Playlist', watchLater: 'Tonton nanti', likedVideos: 'Video yang disukai', myVideos: 'Video saya', explore: 'Jelajahi', music: 'Musik', movies: 'Film', live: 'Live', more: 'Lainnya', liked: 'Drama disukai', hero: 'Cerita pendek,', accent: 'emosi besar.', watch: 'Mulai menonton', picks: 'Drama pilihan minggu ini', shortTitle: 'Shorts', viewAll: 'Lihat semua →', videoCount: 'video', channels: 'Ikuti cerita favoritmu', genres: ['Semua', 'Romansa', 'Balas dendam', 'Keluarga', 'Rahasia', 'Kisah kedua'], follow: 'Ikuti', following: 'Mengikuti', empty: 'Belum ada drama yang cocok.', loading: 'Memuat dari server…', apiFallback: 'Server belum tersedia, menampilkan katalog demo sementara.', playbackUnavailable: 'Pilih episode untuk mulai menonton.', swipeHint: 'Geser ke atas untuk menemukan cerita berikutnya' },
en: { code: 'en', htmlLang: 'en', title: 'YMB · Short stories, big emotions', description: 'Discover the best short dramas and new episodes every day.', search: 'Search dramas, actors, or genres', searchResults: 'Search results for', noResults: 'No results for', clearSearch: 'Clear search', create: 'Create', notifications: 'Notifications', account: 'Account', home: 'Home', shorts: 'Shorts', subscriptions: 'Subscriptions', library: 'Library', history: 'History', clearHistory: 'Clear all', historyEmpty: 'No watch history yet.', comments: 'Comments', noComments: 'No comments yet.', commentPlaceholder: 'Add a comment…', send: 'Send', share: 'Share', save: 'Save', like: 'Like', linkCopied: 'Link copied', subscribe: 'Subscribe', subscribed: 'Subscribed', uploadVideo: 'Upload video', uploadHint: 'Drag and drop a video file here, or click to browse', uploadHintSub: 'MP4, MOV or WebM', uploadTitleLabel: 'Title', uploadTitlePlaceholder: 'Give your video a title', uploadCancel: 'Cancel', uploadSubmit: 'Upload', uploadSuccessTitle: 'Video submitted for review', uploadSuccessText: 'Once approved, it will appear on your channel.', uploadDone: 'Done', close: 'Close', episodes: 'Episodes', episodeSwipeHint: 'Swipe up/down to change episodes', lockedEpisode: 'Login to unlock this episode.', soundHint: 'Tap for sound', about: 'About YMB', copyright: 'YMB · Your Mini Business. All rights reserved.', you: 'You', playlists: 'Playlists', watchLater: 'Watch later', likedVideos: 'Liked videos', myVideos: 'Your videos', explore: 'Explore', music: 'Music', movies: 'Movies', live: 'Live', more: 'Show more', liked: 'Liked dramas', hero: 'Short stories,', accent: 'big emotions.', watch: 'Watch now', picks: 'Drama picks this week', shortTitle: 'Shorts', viewAll: 'View all →', videoCount: 'videos', channels: 'Follow your favorite stories', genres: ['All', 'Romance', 'Revenge', 'Family', 'Secrets', 'Second chance'], follow: 'Follow', following: 'Following', empty: 'No dramas match your search.', loading: 'Loading from server…', apiFallback: 'Server unavailable, showing the demo catalog temporarily.', playbackUnavailable: 'Select an episode to start watching.', swipeHint: 'Swipe up to discover the next story' },
zh: { code: 'zh', htmlLang: 'zh-CN', title: 'YMB · 短故事,大情绪', description: '发现精彩短剧,每天更新新剧集。', search: '搜索短剧、演员或题材', searchResults: '搜索结果', noResults: '没有找到相关结果', clearSearch: '清空搜索', create: '创建', notifications: '通知', account: '账号菜单', home: '首页', shorts: 'Shorts', subscriptions: '订阅', library: '媒体库', history: '观看记录', clearHistory: '清空记录', historyEmpty: '还没有观看记录。', comments: '评论', noComments: '还没有评论。', commentPlaceholder: '添加评论…', send: '发送', share: '分享', save: '收藏', like: '点赞', linkCopied: '链接已复制', subscribe: '订阅', subscribed: '已订阅', uploadVideo: '上传视频', uploadHint: '拖拽视频文件到此处,或点击选择', uploadHintSub: '支持 MP4 / MOV / WebM', uploadTitleLabel: '标题', uploadTitlePlaceholder: '为视频添加标题', uploadCancel: '取消', uploadSubmit: '上传', uploadSuccessTitle: '视频已提交审核', uploadSuccessText: '审核通过后会在你的频道中展示。', uploadDone: '完成', close: '关闭', episodes: '剧集', episodeSwipeHint: '上下滑动切换剧集', lockedEpisode: '登录后解锁本集。', soundHint: '点击开启声音', about: '关于 YMB', copyright: 'YMB · Your Mini Business. 版权所有。', you: '我', playlists: '播放列表', watchLater: '稍后观看', likedVideos: '赞过的视频', myVideos: '我的视频', explore: '探索', music: '音乐', movies: '电影', live: '直播', more: '展开', liked: '喜欢的短剧', hero: '短故事,', accent: '大情绪。', watch: '立即观看', picks: '本周精选短剧', shortTitle: 'Shorts', viewAll: '查看全部 →', videoCount: '个视频', channels: '关注你喜欢的故事', genres: ['全部', '爱情', '复仇', '家庭', '秘密', '重生'], follow: '关注', following: '已关注', empty: '没有找到匹配的短剧。', loading: '正在从服务端加载…', apiFallback: '服务端暂不可用,暂时展示演示目录。', playbackUnavailable: '请选择剧集开始播放。', swipeHint: '向上滑动发现下一个故事' }
};
export function getLocale(code) { return locales[code] || locales.id; }
+23 -73
View File
@@ -1,82 +1,32 @@
// Author: fruit.com.cn
import { Bell, Bookmark, ChevronDown, ChevronRight, Compass, Film, History, Home, ListVideo, Menu, Mic, Music2, Plus, Radio, Search, ThumbsUp, UserRound, Video, X } from 'lucide-react';
import { useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { Suspense, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useLocale, useUi } from '../app/providers/AppProviders';
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 [searchOpen, setSearchOpen] = useState(false);
const primaryItems = [[Home, locale.home, '/'], [Compass, locale.shorts, '/shorts'], [UserRound, locale.subscriptions, '/subscriptions']];
const libraryItems = [[History, locale.history, '/history'], [ListVideo, locale.playlists, '/library'], [Bookmark, locale.watchLater, '/library'], [ThumbsUp, locale.likedVideos, '/library'], [Video, locale.myVideos, '/library']];
const exploreItems = [[Music2, locale.music, '/search?genre=music'], [Film, locale.movies, '/search?genre=movies'], [Radio, locale.live, '/shorts']];
const submitSearch = (event) => {
event.preventDefault();
navigate(`/search?q=${encodeURIComponent(query)}`);
};
const { locale } = useLocale();
const { sidebarOpen, setSidebarOpen } = useUi();
const location = useLocation();
const [uploadOpen, setUploadOpen] = useState(false);
const immersive = location.pathname === '/shorts';
return (
<>
<header className="topbar">
<button className="icon-button topbar-menu" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label="Menu" type="button">
<Menu size={22} />
</button>
<NavLink className="brand" to="/" aria-label="YMB">
<img src="/brand/ymb-logo-blue.png" alt="YMB" />
</NavLink>
<form className={`search-form ${searchOpen ? 'open' : ''}`} onSubmit={submitSearch}>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={locale.search} aria-label={locale.search} />
<button aria-label="Search" type="submit"><Search size={19} /></button>
</form>
<button className="voice-search icon-button" aria-label={locale.voiceSearch} type="button"><Mic size={20} /></button>
<div className="top-actions">
<button className="icon-button mobile-search" onClick={() => setSearchOpen(!searchOpen)} aria-label={locale.search} type="button"><Search size={20} /></button>
<LanguageSwitcher />
<button className="create-button" type="button"><Plus size={19} /><span>{locale.create}</span></button>
<button className="icon-button notification-button" aria-label={locale.notifications} type="button"><Bell size={19} /></button>
<span className="profile-chip" aria-label={locale.account}>N</span>
</div>
</header>
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
<nav className="sidebar-group">
{primaryItems.map(([Icon, label, to]) => (
<NavLink to={to} end={to === '/'} key={to}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
</nav>
<div className="nav-rule" />
<div className="sidebar-group">
<div className="sidebar-group-title"><span>{locale.you}</span><ChevronRight size={16} /></div>
{libraryItems.map(([Icon, label, to]) => (
<NavLink to={to} key={label}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
</div>
<div className="nav-rule" />
<div className="sidebar-group">
<div className="sidebar-group-title">{locale.explore}</div>
{exploreItems.map(([Icon, label, to]) => (
<NavLink to={to} key={label}>
<Icon size={19} />
<span className="sidebar-label">{label}</span>
</NavLink>
))}
<button className="genre-link" type="button"><ChevronDown size={19} /><span className="sidebar-label">{locale.more}</span></button>
</div>
</aside>
<main className={`content ${sidebarOpen ? '' : 'content-wide'}`}><Outlet /></main>
<nav className="bottom-nav">
{primaryItems.map(([Icon, label, to]) => <NavLink to={to} key={to}><Icon size={19} />{label}</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>
{sidebarOpen && (
<button className="scrim mobile-only" onClick={() => setSidebarOpen(false)} aria-label="Close" type="button" />
)}
<UploadVideoDialog open={uploadOpen} onClose={() => setUploadOpen(false)} />
</>
);
}
+39
View File
@@ -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>
);
}
+40
View File
@@ -0,0 +1,40 @@
// Author: fruit.com.cn
// 用内联 SVG 绘制国旗,避免 Windows 等系统不渲染 emoji 国旗
const STAR = 'M0,-1 L0.2245,-0.309 L0.951,-0.309 L0.363,0.118 L0.588,0.809 L0,0.382 L-0.588,0.809 L-0.363,0.118 L-0.951,-0.309 L-0.2245,-0.309 Z';
const FLAGS = {
id: (
<svg viewBox="0 0 3 2" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="3" height="2" fill="#fff" />
<rect width="3" height="1" fill="#ce1126" />
</svg>
),
en: (
<svg viewBox="0 0 3 2" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="3" height="2" fill="#fff" />
{[0, 2, 4, 6, 8, 10, 12].map((index) => (
<rect key={index} y={(index * 2) / 13} width="3" height={2 / 13} fill="#b22234" />
))}
<rect width="1.2" height={14 / 13} fill="#3c3b6e" />
{[0, 1, 2, 3, 4].map((row) => (
[0, 1, 2, 3, 4, 5].map((col) => (
<circle key={`${row}-${col}`} cx={0.1 + col * 0.2} cy={0.11 + row * 0.22} r={0.05} fill="#fff" />
))
))}
</svg>
),
zh: (
<svg viewBox="0 0 3 2" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="3" height="2" fill="#de2910" />
<path d={STAR} transform="translate(0.5 0.5) scale(0.28)" fill="#ffde00" />
<path d={STAR} transform="translate(1 0.28) scale(0.1)" fill="#ffde00" />
<path d={STAR} transform="translate(1.18 0.5) scale(0.1)" fill="#ffde00" />
<path d={STAR} transform="translate(1.18 0.74) scale(0.1)" fill="#ffde00" />
<path d={STAR} transform="translate(1 0.96) scale(0.1)" fill="#ffde00" />
</svg>
),
};
export function LanguageFlag({ code }) {
return <span className="language-flag">{FLAGS[code] || FLAGS.id}</span>;
}
@@ -0,0 +1,43 @@
// Author: fruit.com.cn
import { Check, ChevronDown } from 'lucide-react';
import { useState } from 'react';
import { useLocale } from '../../app/providers/AppProviders';
import { LanguageFlag } from './LanguageFlag';
const languages = [
{ code: 'id', label: 'Bahasa Indonesia', short: 'ID' },
{ code: 'en', label: 'English', short: 'EN' },
{ code: 'zh', label: '简体中文', short: '中文' },
];
const titles = { id: 'Bahasa', en: 'Language', zh: '语言' };
export function LanguageSwitcher() {
const { lang, setLang } = useLocale();
const [open, setOpen] = useState(false);
const current = languages.find((item) => item.code === lang) || languages[0];
const title = titles[lang] || 'Language';
return (
<div className="language-switcher">
<button className="language-trigger" onClick={() => setOpen(!open)} aria-expanded={open} aria-label={title} type="button">
<LanguageFlag code={current.code} />
<span className="language-code">{current.short}</span>
<ChevronDown size={14} className="language-chevron" />
</button>
{open && (
<>
<button className="language-backdrop" onClick={() => setOpen(false)} aria-label="Close language menu" type="button" />
<div className="language-menu">
<div className="language-menu-header">{title}</div>
{languages.map((item) => (
<button key={item.code} className={item.code === lang ? 'selected' : ''} onClick={() => { setLang(item.code); setOpen(false); }} type="button">
<LanguageFlag code={item.code} />
<span className="language-option-label">{item.label}</span>
{item.code === lang && <Check size={16} className="language-option-check" />}
</button>
))}
</div>
</>
)}
</div>
);
}
+47
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
@@ -0,0 +1,100 @@
// Author: fruit.com.cn
import { CheckCircle2, UploadCloud, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useLocale } from '../../app/providers/AppProviders';
function formatSize(bytes) {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB`;
}
export function UploadVideoDialog({ open, onClose }) {
const { locale } = useLocale();
const [file, setFile] = useState(null);
const [title, setTitle] = useState('');
const [dragging, setDragging] = useState(false);
const [submitted, setSubmitted] = useState(false);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!open) return undefined;
setFile(null);
setTitle('');
setDragging(false);
setSubmitted(false);
const onKeyDown = (event) => {
if (event.key === 'Escape') onCloseRef.current();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open]);
if (!open) return null;
const pickFile = (files) => {
const next = files?.[0];
if (next?.type?.startsWith('video/')) setFile(next);
};
return (
<div
className="upload-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="upload-dialog" role="dialog" aria-modal="true" aria-labelledby="upload-dialog-title">
<header>
<h2 id="upload-dialog-title">{locale.uploadVideo}</h2>
<button className="icon-button" type="button" aria-label={locale.close} onClick={onClose}><X size={20} /></button>
</header>
{submitted ? (
<div className="upload-done">
<CheckCircle2 size={44} />
<h3>{locale.uploadSuccessTitle}</h3>
<p>{locale.uploadSuccessText}</p>
<button className="upload-submit" type="button" onClick={onClose}>{locale.uploadDone}</button>
</div>
) : (
<>
<label
className={`upload-drop ${dragging ? 'dragging' : ''}`}
onDragOver={(event) => { event.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(event) => { event.preventDefault(); setDragging(false); pickFile(event.dataTransfer.files); }}
>
<input type="file" accept="video/*" hidden onChange={(event) => pickFile(event.target.files)} />
<UploadCloud size={34} />
<b>{file ? file.name : locale.uploadHint}</b>
<small>{file ? formatSize(file.size) : locale.uploadHintSub}</small>
</label>
<label className="upload-field">
<span>{locale.uploadTitleLabel}</span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={locale.uploadTitlePlaceholder}
maxLength={100}
/>
</label>
<footer className="upload-actions">
<button className="upload-cancel" type="button" onClick={onClose}>{locale.uploadCancel}</button>
<button
className="upload-submit"
type="button"
disabled={!file || !title.trim()}
onClick={() => setSubmitted(true)}
>
{locale.uploadSubmit}
</button>
</footer>
</>
)}
</div>
</div>
);
}
+3 -3
View File
@@ -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 />);
-91
View File
@@ -1,91 +0,0 @@
// Author: fruit.com.cn
import { useEffect, useMemo, useState } from 'react';
import { MediaCard } from '../components/MediaCard';
import { SectionHeading } from '../components/SectionHeading';
import { videos } from '../data/catalog';
import { useAppStore } from '../app/store';
import { searchCatalog } from '../services/catalogService';
import { appApi } from '../services/appApi';
import { normalizeDrama, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
const SHORTS_LIMIT = 5;
const FIRST_SHELF_LIMIT = 5;
const ALL_GENRES_INDEX = 0;
function normalizeHomeFeed(payload, localeCode) {
const sections = payload?.sections || [];
const dramas = sections.flatMap((section) => section.dramas || []);
return uniqueDramas(dramas.map((item) => normalizeDrama(item, localeCode)));
}
function filterByGenre(items, genre, localeGenres) {
if (!genre || genre === localeGenres[ALL_GENRES_INDEX]) return items;
const filtered = items.filter((item) => item.genre === genre);
return filtered.length ? filtered : items;
}
export function HomePage() {
const { locale } = useAppStore();
const categoryItems = locale.homeCategories || locale.genres;
const [genre, setGenre] = useState(categoryItems[ALL_GENRES_INDEX]);
const api = useApi(
() => appApi.homeFeed().then((payload) => normalizeHomeFeed(payload, locale.code)),
[locale.code],
);
const remoteItems = api.data || [];
const fallbackItems = searchCatalog('', genre, locale.genres.indexOf(genre));
const items = useMemo(
() => filterByGenre(remoteItems.length ? remoteItems : fallbackItems, genre, categoryItems),
[categoryItems, fallbackItems, genre, remoteItems],
);
const shortsItems = (remoteItems.length ? remoteItems : videos).slice(0, SHORTS_LIMIT);
const firstShelf = items.slice(0, FIRST_SHELF_LIMIT);
const secondShelf = items.slice(FIRST_SHELF_LIMIT);
useEffect(() => {
setGenre(categoryItems[ALL_GENRES_INDEX]);
}, [categoryItems, locale.code]);
return (
<>
<section className="youtube-home-feed" aria-label={locale.home}>
<div className="youtube-category-bar" role="tablist" aria-label="Genre filters">
{categoryItems.map((item) => (
<button
className={`youtube-category-chip ${genre === item ? 'active' : ''}`}
key={item}
type="button"
role="tab"
aria-selected={genre === item}
onClick={() => setGenre(item)}
>
{item}
</button>
))}
</div>
{api.loading && <div className="api-note">{locale.loading}</div>}
{api.error && <div className="api-note warning">{locale.apiFallback}</div>}
<div className="video-grid" id="recommended-title">
{firstShelf.map((video) => <MediaCard key={video.id} video={video} />)}
</div>
</section>
<section className="youtube-shorts-shelf" aria-labelledby="shorts-title">
<div className="youtube-shelf-heading">
<h2 id="shorts-title"><span className="shorts-mark">▶</span>{locale.shortTitle}</h2>
<button type="button">{locale.explore}</button>
</div>
<div className="shorts-row" id="shorts-title">
{shortsItems.map((video) => <MediaCard key={`short-${video.id}`} video={video} compact />)}
</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>}
</>
);
}
-10
View File
@@ -1,10 +0,0 @@
// Author: fruit.com.cn
import { MediaCard } from '../components/MediaCard';
import { SectionHeading } from '../components/SectionHeading';
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} /><div className="api-note">{api.loading ? locale.loading : api.error ? locale.apiFallback : ''}</div><div className="video-grid">{items.map((video) => <MediaCard key={video.id} video={video} />)}</div></>; }
-11
View File
@@ -1,11 +0,0 @@
// Author: fruit.com.cn
import { useSearchParams } from 'react-router-dom';
import { MediaCard } from '../components/MediaCard';
import { SectionHeading } from '../components/SectionHeading';
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';
export function SearchPage() { const { locale } = useAppStore(); const [params] = useSearchParams(); const query = params.get('q') || ''; const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const source = api.data?.length ? api.data : searchCatalog('', '', 0); const normalizedQuery = query.toLowerCase(); const results = source.filter((video) => `${video.title} ${video.channel} ${video.genre}`.toLowerCase().includes(normalizedQuery)); return <><SectionHeading title={query ? `Search results for “${query}”` : locale.picks} /><div className="api-note">{api.loading ? locale.loading : api.error ? locale.apiFallback : ''}</div><div className="video-grid">{results.map((video) => <MediaCard key={video.id} video={video} />)}</div></>; }
-13
View File
@@ -1,13 +0,0 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { Play } from 'lucide-react';
import { videos } from '../data/catalog';
import { appApi } from '../services/appApi';
import { normalizePage, normalizeDrama, selectPlaybackUrl, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
import { useAppStore } from '../app/store';
import { VideoPlayer } from '../components/player/VideoPlayer';
function FeedItem({ video }) { const ref = useRef(null); const [active, setActive] = useState(false); const playback = useApi(() => video.preview?.id ? appApi.episodePlay(video.preview.id) : Promise.resolve(null), [video.preview?.id]); const src = selectPlaybackUrl(playback.data, video.preview?.playUrl); useEffect(() => { const element = ref.current; const observer = new IntersectionObserver(([entry]) => setActive(entry.isIntersecting), { threshold: 0.7 }); observer.observe(element); return () => observer.disconnect(); }, []); return <article ref={ref} className="feed-item"><div className="feed-video-shell">{src ? <VideoPlayer src={src} autoPlay={active} /> : <img src={video.image} alt={video.title} />}</div><div className="feed-overlay"><span className="feed-badge">YMB SHORTS</span><h2>{video.title}</h2><p>{video.channel}</p><Link to={`/watch/${video.id}`} className="primary-button"><Play size={15} fill="currentColor" /> {video.watch || 'Watch drama'}</Link></div></article>; }
export function ShortsFeedPage() { const { locale } = useAppStore(); const api = useApi(() => appApi.dramas().then((payload) => uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, locale.code)))), [locale.code]); const items = api.data || []; return <section className="shorts-feed-page"><div className="feed-heading"><div><span>YMB SHORTS</span><h1>{locale.shortTitle}</h1></div><p>{locale.swipeHint || 'Swipe up to discover the next story'}</p></div>{api.loading && <div className="api-note">Loading real shorts…</div>}{api.error && <div className="api-note warning">{api.error}</div>}{!api.loading && !items.length && <div className="api-note">{locale.empty}</div>}{items.map((video) => <FeedItem key={video.id} video={video} />)}</section>; }
-40
View File
@@ -1,40 +0,0 @@
// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAppStore } from '../app/store';
import { appApi } from '../services/appApi';
import { normalizeDrama, normalizePage, selectPlaybackUrl, uniqueDramas } from '../services/normalizers';
import { useApi } from '../hooks/useApi';
import { VideoPlayer } from '../components/player/VideoPlayer';
function SwipeVideo({ video, active }) {
const ref = useRef(null); const playback = useApi(() => video.preview?.id ? appApi.episodePlay(video.preview.id) : Promise.resolve(null), [video.preview?.id]); const src = selectPlaybackUrl(playback.data, video.preview?.playUrl); const image = video.image;
useEffect(() => { const node = ref.current; const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) node.classList.add('visible'); else node.classList.remove('visible'); }, { threshold: 0.7 }); observer.observe(node); return () => observer.disconnect(); }, []);
return <article ref={ref} className={`single-video-slide ${active ? 'active' : ''}`}><div className="single-video-stage">{src ? <VideoPlayer src={src} poster={image} autoPlay={active} /> : <div className="single-video-placeholder"><img src={image} alt={video.title} /><span>{playback.loading ? 'Loading video…' : playback.error || 'Preview unavailable'}</span></div>}</div><div className="single-video-info"><span>YMB SHORTS</span><h1>{video.title}</h1><p>{video.channel} · {video.views}</p></div></article>;
}
function normalizeFeedItems(payload, localeCode) {
return uniqueDramas(normalizePage(payload).list.map((item) => normalizeDrama(item, localeCode)));
}
async function loadWatchFeed(localeCode) {
try {
const page = normalizeFeedItems(await appApi.dramas(), localeCode);
if (page.length) return page;
} catch (error) {
const fallback = await appApi.homeFeed();
const items = fallback?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
}
const fallback = await appApi.homeFeed();
const items = fallback?.sections?.flatMap((section) => section.dramas || []) || [];
return uniqueDramas(items.map((item) => normalizeDrama(item, localeCode)));
}
export function WatchPage() {
const { id } = useParams(); const navigate = useNavigate(); const { locale } = useAppStore(); const [activeId, setActiveId] = useState(id); const current = useApi(() => appApi.drama(id).then((data) => normalizeDrama(data, locale.code)), [id, locale.code]); const feed = useApi(() => loadWatchFeed(locale.code), [locale.code]); const items = feed.data || []; const ordered = uniqueDramas([current.data, ...items.filter((item) => item.id !== String(id))].filter(Boolean));
const activeIndex = Math.max(0, ordered.findIndex((item) => String(item.id) === String(activeId))); const feedRef = useRef(null); const touchStart = useRef(0); const scrollToIndex = (index) => { const node = feedRef.current; if (!node) return; node.scrollTo({ top: index * node.clientHeight, behavior: 'smooth' }); }; const move = (delta) => { if (ordered.length < 2) return; scrollToIndex(Math.min(ordered.length - 1, Math.max(0, activeIndex + delta))); };
useEffect(() => { const node = feedRef.current; if (!node) return undefined; const observer = new IntersectionObserver((entries) => entries.forEach((entry) => { if (entry.isIntersecting) { const nextId = entry.target.dataset.id; setActiveId(nextId); if (nextId !== id) window.history.replaceState(null, '', `/watch/${nextId}`); } }), { root: node, threshold: 0.75 }); node.querySelectorAll('[data-id]').forEach((item) => observer.observe(item)); const keydown = (event) => { if (event.key === 'ArrowDown' || event.key === 'PageDown') { event.preventDefault(); move(1); } if (event.key === 'ArrowUp' || event.key === 'PageUp') { event.preventDefault(); move(-1); } }; window.addEventListener('keydown', keydown); return () => { observer.disconnect(); window.removeEventListener('keydown', keydown); }; }, [ordered.length, id, activeIndex]);
const hasNext = feed.loading || activeIndex < ordered.length - 1; const hasPrevious = activeIndex > 0;
return <div className="single-video-page"><header className="single-video-toolbar"><button onClick={() => navigate(-1)}>←</button><img src="/brand/ymb-logo-blue.png" alt="YMB" /><span>{locale.swipeHint || 'Swipe up to discover the next story'}</span></header>{(current.loading || feed.loading) && <div className="single-video-loading api-note">{locale.loading}</div>}{feed.error && <div className="single-video-loading api-note warning">{feed.error}</div>}<div ref={feedRef} className="single-video-feed" onTouchStart={(event) => { touchStart.current = event.touches[0].clientY; }} onTouchEnd={(event) => { const distance = touchStart.current - event.changedTouches[0].clientY; if (Math.abs(distance) > 45) move(distance > 0 ? 1 : -1); }} onWheel={(event) => { if (Math.abs(event.deltaY) > 20) move(event.deltaY > 0 ? 1 : -1); }}>{ordered.map((video) => <div key={video.id} data-id={video.id}><SwipeVideo video={video} active={String(activeId) === String(video.id)} /></div>)}</div><div className="feed-arrows"><button onClick={() => move(-1)} disabled={!hasPrevious} aria-label="Previous video">↑</button><button onClick={() => move(1)} disabled={!hasNext} aria-label="Next video">↓</button></div></div>;
}
-11
View File
@@ -1,11 +0,0 @@
// Author: fruit.com.cn
import { request } from './httpClient';
export const appApi = {
homeFeed: () => request('/app/home/feed'),
categories: () => request('/app/categories'),
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`),
progress: (body) => request('/app/watch/progress', { method: 'POST', body: JSON.stringify(body) }),
};
-22
View File
@@ -1,22 +0,0 @@
// Author: fruit.com.cn
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', ...(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);
}
}
-24
View File
@@ -1,24 +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 || '', 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); }
+129
View File
@@ -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;
}
+10
View File
@@ -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',
},
};
+10
View File
@@ -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),
};
+36
View File
@@ -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';
}
+9
View File
@@ -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());
+22
View File
@@ -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;
}
+9
View File
@@ -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}` : '';
}
+9
View File
@@ -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),
};
+16
View File
@@ -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;
}
+81
View File
@@ -0,0 +1,81 @@
// Author: fruit.com.cn
import { useEffect, useState } from 'react';
import { Send, X } from 'lucide-react';
import { engagementApi } from '../api/engagement';
function formatTime(value, htmlLang) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString(htmlLang, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export function CommentsSheet({ video, locale, onClose, onPosted }) {
const [comments, setComments] = useState(null);
const [error, setError] = useState('');
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const loadComments = () => engagementApi.comments({ dramaId: video.id, episodeId: video.preview?.id })
.then((payload) => setComments(payload?.list || []))
.catch((loadError) => { setError(loadError.message || 'Request failed'); setComments([]); });
useEffect(() => {
let active = true;
setComments(null);
setError('');
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; };
}, [video.id, video.preview?.id]);
const submit = async (event) => {
event.preventDefault();
const content = text.trim();
if (!content || sending) return;
setSending(true);
setError('');
try {
await engagementApi.publishComment({ dramaId: video.id, episodeId: video.preview?.id, content });
setText('');
await loadComments();
onPosted?.();
} catch (postError) {
setError(postError.message || 'Request failed');
} finally {
setSending(false);
}
};
return (
<>
<button className="comments-backdrop" onClick={onClose} aria-label="Close comments" type="button" />
<section className="comments-sheet" role="dialog" aria-label={locale.comments}>
<header className="comments-header">
<h2>{locale.comments}</h2>
<button onClick={onClose} aria-label="Close" type="button"><X size={20} /></button>
</header>
<div className="comments-list">
{comments === null && <p className="comments-note">{locale.loading}</p>}
{error && <p className="comments-note warning">{error}</p>}
{comments && !comments.length && !error && <p className="comments-note">{locale.noComments}</p>}
{comments?.map((comment) => (
<div className="comment-row" key={comment.id}>
<span className="comment-avatar">{comment.nickname?.[0] || 'Y'}</span>
<div className="comment-body">
<b>{comment.nickname || 'Guest'}</b>
<p>{comment.content}</p>
<small>{formatTime(comment.createdAt, locale.htmlLang)}</small>
</div>
</div>
))}
</div>
<form className="comment-form" onSubmit={submit}>
<input value={text} onChange={(event) => setText(event.target.value)} placeholder={locale.commentPlaceholder} aria-label={locale.commentPlaceholder} />
<button type="submit" disabled={!text.trim() || sending} aria-label={locale.send}><Send size={18} /></button>
</form>
</section>
</>
);
}
+48
View File
@@ -0,0 +1,48 @@
// Author: fruit.com.cn
import { useLayoutEffect, useRef } from 'react';
const DURATION = 420;
const FADE = 180;
export function HeroTransition({ data, onDone }) {
const ref = useRef(null);
useLayoutEffect(() => {
if (!data) return undefined;
const node = ref.current;
if (!node) return undefined;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
onDone();
return undefined;
}
const rect = data;
const scale = Math.max(window.innerWidth / rect.width, window.innerHeight / rect.height) * 1.04;
const tx = (window.innerWidth - rect.width * scale) / 2 - rect.left;
const ty = (window.innerHeight - rect.height * scale) / 2 - rect.top;
const expand = node.animate(
[
{ transform: 'translate(0, 0) scale(1)', borderRadius: '12px' },
{ transform: `translate(${tx}px, ${ty}px) scale(${scale})`, borderRadius: '0px' },
],
{ duration: DURATION, easing: 'cubic-bezier(.2, .7, .3, 1)', fill: 'forwards' },
);
expand.onfinish = () => {
const fade = node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: FADE, fill: 'forwards' });
fade.onfinish = onDone;
};
return () => expand.cancel();
}, [data, onDone]);
if (!data) return null;
const rect = data;
return (
<div
ref={ref}
className="hero-transition"
aria-hidden="true"
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
>
{data.src && <img src={data.src} alt="" />}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
import { MoreVertical, Play } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useLocale } from '../../app/providers/AppProviders';
import { daysAgoLabel, formatViews } from '../lib/format';
import { setHeroTransition } from '../lib/heroTransition';
export function MediaCard({ video, compact = false, to }) {
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(' · ');
const rememberRect = (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const thumb = event.currentTarget.querySelector('.media-thumb');
const img = thumb?.querySelector('img');
if (!thumb || !img) return;
const rect = thumb.getBoundingClientRect();
setHeroTransition({ left: rect.left, top: rect.top, width: rect.width, height: rect.height, src: img.currentSrc || img.src });
};
return <article className={`media-card ${compact ? 'compact' : ''}`} onClickCapture={rememberRect}><Link to={target} className="media-thumb"><img src={image} alt={video.title} loading="lazy" /><b>{video.duration}</b><span className="thumb-play"><Play size={16} fill="currentColor" /></span></Link>{compact ? <Link to={target} className="short-media-title">{video.title}</Link> : <div className="media-meta"><span className="channel-avatar">{video.channel?.[0] || 'Y'}</span><div className="media-copy"><Link to={target} className="media-title">{video.title}</Link><small>{video.channel}<br />{meta}</small></div><button className="media-more" type="button" aria-label="More actions"><MoreVertical size={18} /></button></div>}</article>;
}
+2
View File
@@ -0,0 +1,2 @@
// Author: fruit.com.cn
export function SectionHeading({ title, action = null }) { return <div className="section-heading"><h2>{title}</h2>{action && <button>{action}</button>}</div>; }
+10
View File
@@ -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,413 @@
// 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, soundHint = 'Tap for sound', onProgress, onEnded }) {
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 [soundBlocked, setSoundBlocked] = useState(false);
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);
setSoundBlocked(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);
setSoundBlocked(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 enableSound = () => {
const video = videoRef.current;
if (!video) return;
video.muted = false;
setIsMuted(false);
setSoundBlocked(false);
persistSoundPreference(true);
video.play().catch(() => {});
};
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)}
onEnded={onEnded}
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" />}
{soundBlocked && !error && (
<button className="vp-sound-hint" type="button" onClick={enableSound}>
<VolumeX size={15} />
<span>{soundHint}</span>
</button>
)}
{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>
);
}
+104
View File
@@ -0,0 +1,104 @@
// Author: fruit.com.cn
function Skeleton({ className = '' }) {
return <span className={`skeleton ${className}`.trim()} aria-hidden="true" />;
}
export function MediaCardSkeleton({ compact = false }) {
return (
<article className="media-card skeleton-card" aria-hidden="true">
<Skeleton className={`skeleton-thumb ${compact ? 'compact' : ''}`} />
{compact ? <Skeleton className="skeleton-short-title" /> : (
<div className="skeleton-meta">
<Skeleton className="skeleton-avatar" />
<div className="skeleton-lines">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
)}
</article>
);
}
export function VideoGridSkeleton({ count = 6, label = 'Loading' }) {
return (
<div className="video-grid" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => <MediaCardSkeleton key={index} />)}
</div>
);
}
export function ShortsShelfSkeleton({ count = 5, label = 'Loading' }) {
return (
<div className="shorts-row" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => <MediaCardSkeleton key={index} compact />)}
</div>
);
}
export function CategoryChipsSkeleton() {
return (
<div className="youtube-category-bar" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => <Skeleton key={index} className="skeleton-chip" />)}
</div>
);
}
export function HistoryGridSkeleton({ count = 6, label = 'Loading' }) {
return (
<div className="video-grid" aria-busy="true" aria-label={label}>
{Array.from({ length: count }, (_, index) => (
<article className="skeleton-card history-skeleton" key={index} aria-hidden="true">
<Skeleton className="skeleton-thumb" />
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</article>
))}
</div>
);
}
export function ShortsSkeleton({ label = 'Loading' }) {
return (
<div className="shorts-skeleton" aria-busy="true" aria-label={label}>
<div className="shorts-skeleton-wrap">
<Skeleton className="shorts-skeleton-screen" />
<div className="shorts-skeleton-rail" aria-hidden="true">
{Array.from({ length: 4 }, (_, index) => <Skeleton key={index} className="skeleton-dot" />)}
</div>
<div className="shorts-skeleton-copy" aria-hidden="true">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
</div>
);
}
export function WatchPageSkeleton({ label = 'Loading' }) {
return (
<div className="watch-page" aria-busy="true" aria-label={label}>
<div className="watch-primary">
<Skeleton className="watch-skeleton-player" />
<Skeleton className="watch-skeleton-title" />
<div className="watch-skeleton-row">
<Skeleton className="skeleton-avatar" />
<Skeleton className="watch-skeleton-channel" />
</div>
<Skeleton className="watch-skeleton-description" />
</div>
<aside className="watch-side">
<Skeleton className="watch-skeleton-heading" />
{Array.from({ length: 4 }, (_, index) => (
<div className="watch-side-card" key={index} aria-hidden="true">
<Skeleton className="watch-side-thumb" />
<div className="skeleton-lines">
<Skeleton className="skeleton-line" />
<Skeleton className="skeleton-line short" />
</div>
</div>
))}
</aside>
</div>
);
}
File renamed without changes.
File renamed without changes.
+56
View File
@@ -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,
};
}
+29
View File
@@ -0,0 +1,29 @@
// Author: fruit.com.cn
import { useState } from 'react';
const FOLLOW_KEY = 'ymb.shorts.following';
function readFollowedChannels() {
try {
const stored = JSON.parse(window.localStorage.getItem(FOLLOW_KEY) || '[]');
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
}
export function useFollowedChannels() {
const [channels, setChannels] = useState(readFollowedChannels);
const toggle = (channel) => {
setChannels((prev) => {
const next = prev.includes(channel) ? prev.filter((item) => item !== channel) : [...prev, channel];
try {
window.localStorage.setItem(FOLLOW_KEY, JSON.stringify(next));
} catch (error) {
console.error('YMB FOLLOW STORAGE FAILED', error);
}
return next;
});
};
return { channels, toggle };
}
+23
View File
@@ -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 };
}
+41
View File
@@ -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 };
}
+24
View File
@@ -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 };
}
+21
View File
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
const DEVICE_KEY = 'ymb.device.id';
function createDeviceId() {
const uuid = globalThis.crypto?.randomUUID?.() || `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
return `d-${uuid}`;
}
let cachedDeviceId = '';
export function getDeviceId() {
if (cachedDeviceId) return cachedDeviceId;
try {
const stored = window.localStorage.getItem(DEVICE_KEY);
cachedDeviceId = stored || createDeviceId();
if (!stored) window.localStorage.setItem(DEVICE_KEY, cachedDeviceId);
} catch {
cachedDeviceId = createDeviceId();
}
return cachedDeviceId;
}
+39
View File
@@ -0,0 +1,39 @@
// Author: fruit.com.cn
const UNITS = {
id: [[1e9, 'M'], [1e6, 'jt'], [1e3, 'rb']],
en: [[1e9, 'B'], [1e6, 'M'], [1e3, 'K']],
zh: [[1e8, '亿'], [1e4, '万']],
};
const DAYS_AGO = { id: '2 hari lalu', en: '2 days ago', zh: '2天前' };
const VIEW_LABEL = { id: 'tayangan', en: 'views', zh: '次观看' };
function formatDecimal(value, code) {
const rounded = value >= 100 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '');
return code === 'id' ? rounded.replace('.', ',') : rounded;
}
export function formatCount(value, code = 'id') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return value == null ? '' : String(value);
for (const [size, unit] of UNITS[code] || UNITS.id) {
if (numeric >= size) {
const separator = code === 'id' ? ' ' : '';
return `${formatDecimal(numeric / size, code)}${separator}${unit}`;
}
}
return String(Math.round(numeric));
}
export function formatViews(value, code = 'id') {
if (value === null || value === undefined || value === '') return '';
if (typeof value === 'string' && !Number.isFinite(Number(value))) return value;
const count = formatCount(value, code);
if (!count) return '';
const label = VIEW_LABEL[code] || VIEW_LABEL.id;
return code === 'zh' ? `${count}${label}` : `${count} ${label}`;
}
export function daysAgoLabel(code = 'id') {
return DAYS_AGO[code] || DAYS_AGO.id;
}
+24
View File
@@ -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);
}
}
+16
View File
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
// 记录用户点击卡片的封面与位置,用于进入播放页时的放大过渡
let pending = null;
export function setHeroTransition(data) {
pending = data;
}
export function getHeroTransition() {
const data = pending;
if (!data) return null;
window.setTimeout(() => {
if (pending === data) pending = null;
}, 1200);
return data;
}
+18
View File
@@ -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);
}
+5
View File
@@ -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 || [] };
}
File renamed without changes.
+5
View File
@@ -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 || '';
}
+22
View File
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
import { watchApi } from '../api/watch';
const REPORT_INTERVAL_MS = 10000;
const lastReportedAt = new Map();
export function reportWatchProgress(episodeId, positionSeconds, durationSeconds, source = 'web', force = false) {
// 剧集 ID 为 19 位长整型,超出 JS 安全整数范围,必须保持字符串,禁止 Number 转换
const id = String(episodeId ?? '');
if (!/^\d+$/.test(id)) return;
const now = Date.now();
if (!force && now - (lastReportedAt.get(id) || 0) < REPORT_INTERVAL_MS) return;
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));
watchApi.report({
episodeId: id,
positionMs,
...(durationMs > 0 ? { durationMs, finished: durationMs - positionMs < 1000 } : {}),
source,
}).catch(() => {});
}
File renamed without changes.
File renamed without changes.
+615
View File
@@ -0,0 +1,615 @@
/* Author: fruit.com.cn */
:root {
--bg: #0f0f0f;
--surface: #181818;
--line: #303030;
--text: #f1f1f1;
--muted: #aaa;
--accent: #ff4b3e;
--sidebar: 240px;
--topbar: 56px;
}
body { background: var(--bg); color: var(--text); font-family: Roboto, Arial, Helvetica, sans-serif; }
/* 顶部栏:左中右三段,搜索框居中 */
.topbar { position: sticky; top: 0; z-index: 30; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 560px) minmax(0, 1fr); align-items: center; column-gap: 16px; height: var(--topbar); padding: 0 16px; background: var(--bg); border-bottom: 0; box-shadow: none; }
.topbar-left { display: flex; align-items: center; gap: 16px; min-width: 0; }
.topbar-menu { width: 40px; height: 40px; border-radius: 50%; color: var(--text); cursor: pointer; }
.topbar-menu:hover, .notification-button:hover, .mobile-search:hover { background: #272727; }
.brand { display: flex; align-items: center; height: 40px; min-width: 0; }
.brand img { width: 90px; height: 30px; object-fit: contain; }
.topbar-center { display: flex; align-items: center; gap: 8px; min-width: 0; }
.search-form { display: flex; flex: 1; min-width: 0; height: 40px; max-width: none; margin: 0; border-radius: 20px; }
.search-field { position: relative; display: flex; flex: 1; min-width: 0; }
.search-form input { width: 100%; height: 40px; padding: 0 42px 0 16px; border: 1px solid #303030; border-radius: 20px 0 0 20px; background: #121212; color: var(--text); font-size: 14px; }
.search-form input::placeholder { color: #888; }
.search-form input:focus { outline: none; border-color: #1c62b9; }
.search-form button { flex: none; display: grid; place-items: center; width: 64px; height: 40px; border: 1px solid #303030; border-left: 0; border-radius: 0 20px 20px 0; background: #222; color: var(--text); cursor: pointer; }
.search-form button:hover { background: #303030; }
.search-form .search-field .search-clear { position: absolute; right: 8px; top: 50%; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: #303030; color: #ddd; transform: translateY(-50%); cursor: pointer; }
.search-form .search-field .search-clear:hover { background: #444; }
.search-empty { display: grid; place-items: center; gap: 8px; padding: 56px 0; color: #aaa; }
.search-empty b { color: #fff; font-size: 16px; font-weight: 500; }
.top-actions { display: flex; align-items: center; justify-self: end; gap: 10px; min-width: 0; color: var(--text); }
.mobile-search { display: none; }
.create-wrap { position: relative; }
.create-button { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; border-radius: 18px; background: #272727; color: var(--text); font-weight: 600; cursor: pointer; }
.create-button:hover { background: #343434; }
.create-menu { position: absolute; top: calc(100% + 8px); right: 0; z-index: 60; min-width: 208px; padding: 8px; border: 1px solid #303030; border-radius: 12px; background: #212121; box-shadow: 0 12px 32px #000a; }
.create-menu button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px 12px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font-size: 14px; text-align: left; cursor: pointer; }
.create-menu button:hover { background: #3a3a3a; }
.upload-backdrop { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; background: #000a; }
.upload-dialog { width: min(560px, 100%); max-height: calc(100vh - 48px); overflow-y: auto; padding: 22px 24px 24px; border: 1px solid #303030; border-radius: 16px; background: #1c1c1c; box-shadow: 0 24px 64px #000c; }
.upload-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.upload-dialog h2 { margin: 0; color: #fff; font-size: 20px; font-weight: 600; }
.upload-drop { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 20px; padding: 36px 20px; border: 1px dashed #3f3f3f; border-radius: 12px; background: #ffffff08; color: #c9c9c9; text-align: center; cursor: pointer; transition: border-color .18s ease, background .18s ease; }
.upload-drop:hover, .upload-drop.dragging { border-color: #7aa7ff; background: #1f6feb1a; }
.upload-drop b { max-width: 100%; color: #fff; font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
.upload-drop small { color: #9a9a9a; font-size: 12px; }
.upload-field { display: flex; flex-direction: column; gap: 8px; margin-top: 18px; }
.upload-field span { color: #cfcfcf; font-size: 13px; }
.upload-field input { height: 42px; padding: 0 14px; border: 1px solid #3a3a3a; border-radius: 10px; background: #121212; color: #fff; font-size: 14px; }
.upload-field input:focus { border-color: #7aa7ff; outline: none; }
.upload-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.upload-cancel, .upload-submit { height: 38px; padding: 0 18px; border: 0; border-radius: 999px; font-size: 14px; font-weight: 600; cursor: pointer; }
.upload-cancel { background: #2c2c2c; color: #eaeaea; }
.upload-cancel:hover { background: #3a3a3a; }
.upload-submit { background: #fff; color: #111; }
.upload-submit:disabled { opacity: .4; cursor: not-allowed; }
.upload-done { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 32px 8px 8px; color: #4ade80; text-align: center; }
.upload-done h3 { margin: 4px 0 0; color: #fff; font-size: 18px; font-weight: 600; }
.upload-done p { margin: 0; color: #a8a8a8; font-size: 13px; }
.upload-done .upload-submit { margin-top: 12px; }
.notification-button { width: 40px; height: 40px; border-radius: 50%; }
.language-trigger { height: 40px; padding: 0 10px; gap: 6px; border: 0; border-radius: 999px; background: transparent; color: var(--text); font-size: 12px; font-weight: 600; }
.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: 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; }
/* 侧边栏:展开 240px,收起 72px 图标 + 文字 */
.sidebar { position: fixed; top: var(--topbar); bottom: 0; left: 0; z-index: 20; width: var(--sidebar); padding: 12px; overflow-y: auto; background: var(--bg); border: 0; box-shadow: none; color: var(--text); }
.sidebar::-webkit-scrollbar { width: 8px; }
.sidebar::-webkit-scrollbar-thumb { background: #4d4d4d; border-radius: 4px; }
.sidebar.closed { width: 72px; padding: 4px 4px 12px; }
.sidebar-group { display: grid; gap: 2px; }
.sidebar a, .genre-link { display: flex; align-items: center; gap: 24px; width: 100%; min-height: 40px; padding: 8px 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text); font-size: 14px; text-align: left; cursor: pointer; }
.sidebar a svg, .genre-link svg { flex: none; width: 24px; height: 24px; }
.sidebar a:hover, .genre-link:hover, .sidebar a.active { background: #272727; color: #fff; }
.sidebar small { display: none; }
.nav-rule { height: 1px; margin: 12px 0; background: var(--line); }
.sidebar.closed a, .sidebar.closed .genre-link { flex-direction: column; justify-content: center; gap: 6px; height: 74px; min-height: 0; padding: 16px 0; border-radius: 10px; font-size: 10px; line-height: 1.2; }
.sidebar.closed a svg, .sidebar.closed .genre-link svg { width: 24px; height: 24px; }
.sidebar.closed .nav-rule { display: block; margin: 8px 12px; }
/* 内容区 */
.content { max-width: none; margin: 0; padding: 12px 24px 80px calc(var(--sidebar) + 24px); }
.content-wide { padding-left: calc(72px + 24px); }
.site-footer { padding: 28px 0 8px; color: #717171; font-size: 12px; text-align: center; }
.youtube-home-feed, .youtube-shorts-shelf { margin-bottom: 40px; }
.youtube-category-bar { position: sticky; top: var(--topbar); z-index: 10; display: flex; gap: 12px; overflow-x: auto; padding: 12px 0; background: var(--bg); scrollbar-width: none; }
.youtube-category-bar::-webkit-scrollbar { display: none; }
.youtube-category-chip { flex: none; border: 0; border-radius: 8px; padding: 8px 14px; background: #272727; color: #fff; white-space: nowrap; cursor: pointer; font-size: 14px; font-weight: 500; }
.youtube-category-chip:hover { background: #3f3f3f; }
.youtube-category-chip.active { background: #f1f1f1; color: #0f0f0f; }
.video-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px 16px; }
.media-card { min-width: 0; }
.media-thumb { border-radius: 12px; background: #222; }
.media-thumb img { display: block; }
.media-meta { align-items: flex-start; gap: 12px; padding-top: 12px; }
.channel-avatar { width: 36px; height: 36px; background: #734c67; color: #fff; font-size: 14px; }
.media-copy { min-width: 0; flex: 1; }
.media-title { display: -webkit-box; overflow: hidden; color: #fff; font-size: 16px; font-weight: 500; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.media-meta small { display: block; margin-top: 4px; color: var(--muted); font-size: 13px; line-height: 1.5; }
.media-more { flex: none; border: 0; background: transparent; color: #fff; padding: 4px; cursor: pointer; }
/* Shorts 集合 */
.youtube-shorts-shelf { padding: 24px 0 26px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.youtube-shelf-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.youtube-shelf-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; color: #fff; font-size: 22px; }
.youtube-shelf-heading button { border: 0; background: transparent; color: #ff8a82; font-size: 14px; font-weight: 600; cursor: pointer; }
.shorts-mark { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 7px; background: var(--accent); color: #fff; font-size: 12px; }
.shorts-row { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; }
.shorts-row .media-thumb { aspect-ratio: 9 / 16; border-radius: 12px; }
.shorts-row .media-meta { display: none; }
.short-media-title { display: -webkit-box; overflow: hidden; margin-top: 9px; color: var(--text); font-size: 14px; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
/* 提示与语言菜单 */
.api-note { margin: 0 0 16px; padding: 10px 12px; border: 0; border-radius: 8px; background: #181818; color: var(--muted); font-size: 13px; }
.api-note.warning { background: #241a1a; color: #e6b8b3; }
.language-menu { top: 48px; width: 208px; padding: 8px; border: 1px solid #333; border-radius: 14px; background: #1f1f1f; box-shadow: 0 20px 50px #000a; }
.language-menu-header { padding: 7px 10px 8px; color: #8a8a8a; font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
.language-menu button { gap: 10px; padding: 10px; border-radius: 9px; color: #ddd; }
.language-menu button:hover { background: #2b2b2b; color: #fff; }
.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: 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; }
.shorts-stage-wide { left: 72px; }
.shorts-scroll { height: 100%; overflow-y: auto; scroll-snap-type: y mandatory; overscroll-behavior-y: contain; scrollbar-width: none; }
.shorts-scroll::-webkit-scrollbar { display: none; }
.short-slide { position: relative; height: 100%; overflow: hidden; background: #050505; scroll-snap-align: start; scroll-snap-stop: always; }
.short-slide-stage { position: relative; display: flex; align-items: center; justify-content: center; width: min(100%, calc((100vh - var(--topbar) - 24px) * 9 / 16)); height: 100%; margin: 0 auto; padding: 12px 0; }
.short-column { display: flex; flex-direction: column; justify-content: center; width: 100%; height: 100%; }
.short-player-wrap { position: relative; flex: 1; min-height: 0; }
.short-player-wrap::after { content: ''; position: absolute; inset: auto 0 0; display: none; height: 32%; background: linear-gradient(transparent, #000b); pointer-events: none; }
.short-slide-player { width: 100%; height: 100%; overflow: hidden; border-radius: 16px; background: #000; transform: translateZ(0); isolation: isolate; }
.short-slide-player .vp, .short-slide-player .video-player { border-radius: inherit; }
.short-slide-player .video-player { width: 100%; height: 100%; object-fit: contain; }
.short-slide-placeholder { position: relative; display: grid; place-items: center; width: 100%; height: 100%; }
.short-slide-placeholder img { width: 100%; height: 100%; object-fit: cover; opacity: .4; }
.short-slide-placeholder span { position: absolute; padding: 10px 14px; border-radius: 8px; background: #000b; color: #fff; }
.short-slide-info { position: absolute; right: calc(100% + 24px); bottom: 12px; width: clamp(0px, calc((100vw - var(--sidebar) - 100%) / 2 - 24px), 340px); margin: 0; padding: 0; pointer-events: auto; text-shadow: none; }
.short-channel { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; pointer-events: auto; }
.short-avatar { display: grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 50%; background: #065fd4; color: #fff; font-size: 14px; font-weight: 700; }
.short-channel-name { color: #fff; font-size: 14px; font-weight: 600; }
.short-follow { border: 0; border-radius: 16px; padding: 6px 14px; background: #fff; color: #111; font-size: 12px; font-weight: 600; cursor: pointer; }
.short-follow.followed { background: #ffffff2b; color: #fff; }
.short-slide-info h1 { display: -webkit-box; overflow: hidden; margin: 6px 0 5px; color: #fff; font-size: 18px; font-weight: 600; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.short-rail { position: absolute; left: calc(100% + 16px); bottom: 20px; z-index: 4; display: grid; gap: 16px; justify-items: center; }
.short-rail button { display: grid; justify-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #fff; cursor: pointer; }
.short-rail .rail-icon { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 50%; background: #ffffff1f; color: #fff; }
.short-rail button:hover .rail-icon { background: #ffffff33; }
.short-rail button.active .rail-icon { background: #ff0033; color: #fff; }
.short-rail small { color: #eee; font-size: 11px; line-height: 1.2; text-shadow: 0 1px 6px #000; }
.shorts-toast { position: fixed; left: 50%; bottom: 28px; z-index: 80; transform: translateX(-50%); max-width: calc(100vw - 32px); padding: 10px 16px; border-radius: 8px; background: #fff; color: #111; font-size: 13px; word-break: break-all; }
.shorts-state { position: absolute; top: 20px; left: 50%; z-index: 8; transform: translateX(-50%); padding: 10px 14px; border-radius: 8px; background: #111c; color: #ddd; font-size: 13px; white-space: nowrap; }
.shorts-state.warning { color: #ffb0a8; }
/* 评论面板 */
.comments-backdrop { position: fixed; top: var(--topbar); right: 0; bottom: 0; left: 0; z-index: 70; border: 0; background: #0008; }
.comments-sheet { position: fixed; top: var(--topbar); right: 0; bottom: 0; z-index: 71; display: flex; flex-direction: column; width: min(420px, 100%); background: #181818; border-left: 1px solid #303030; }
.comments-header { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid #303030; }
.comments-header h2 { margin: 0; color: #fff; font-size: 16px; font-weight: 600; }
.comments-header button { display: grid; place-items: center; border: 0; background: transparent; color: #fff; cursor: pointer; }
.comments-list { flex: 1; display: grid; align-content: start; gap: 16px; padding: 16px; overflow-y: auto; }
.comments-note { margin: 0; color: #aaa; font-size: 13px; }
.comments-note.warning { color: #ffb0a8; }
.comment-row { display: flex; gap: 10px; }
.comment-avatar { display: grid; place-items: center; flex: none; width: 32px; height: 32px; border-radius: 50%; background: #734c67; color: #fff; font-size: 13px; font-weight: 700; }
.comment-body { min-width: 0; }
.comment-body b { color: #fff; font-size: 13px; }
.comment-body p { margin: 4px 0; color: #eee; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.comment-body small { color: #888; font-size: 11px; }
.comment-form { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #303030; }
.comment-form input { flex: 1; min-width: 0; height: 40px; padding: 0 14px; border: 1px solid #3b3b3b; border-radius: 20px; background: #121212; color: #fff; }
.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) 340px; gap: 36px; max-width: 1320px; margin: 0 auto; padding: 8px 0 48px; align-items: start; }
.watch-primary { min-width: 0; }
.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; transform: translateZ(0); isolation: isolate; }
.watch-player .vp, .watch-player .video-player { border-radius: inherit; }
.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: .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: 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-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: 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: #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: 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-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: #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: #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: 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: 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; } }
/* 观看记录 */
.history-heading { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 20px; }
.history-heading h1 { margin: 0; color: #fff; font-size: 22px; font-weight: 600; }
.history-heading button { border: 0; background: transparent; color: #ff8a82; font-size: 14px; font-weight: 600; cursor: pointer; }
.history-card { min-width: 0; }
.history-thumb { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 12px; background: #222; }
.history-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.history-progress { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: #ffffff26; }
.history-progress i { display: block; height: 100%; background: #ff0033; }
.history-title { display: -webkit-box; overflow: hidden; margin-top: 10px; color: #fff; font-size: 15px; font-weight: 500; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.history-card small { display: block; margin-top: 4px; color: #aaa; font-size: 12px; }
/* 骨架屏 */
.skeleton { display: block; border-radius: 8px; background: #272727; animation: skeleton-pulse 1.5s ease-in-out infinite; }
@keyframes skeleton-pulse { 0%, 100% { opacity: .45; } 50% { opacity: .85; } }
@media (prefers-reduced-motion: reduce) { .skeleton { animation: none; opacity: .6; } }
.skeleton-card { pointer-events: none; }
.skeleton-thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; }
.skeleton-thumb.compact { aspect-ratio: 9 / 16; }
.skeleton-meta { display: flex; gap: 12px; padding-top: 12px; }
.skeleton-avatar { flex: none; width: 36px; height: 36px; border-radius: 50%; }
.skeleton-lines { flex: 1; display: grid; gap: 8px; padding-top: 2px; }
.skeleton-line { width: 92%; height: 15px; }
.skeleton-line.short { width: 58%; height: 12px; }
.skeleton-short-title { width: 90%; height: 14px; margin-top: 10px; }
.skeleton-chip { flex: none; width: 72px; height: 34px; border-radius: 8px; }
.skeleton-chip:first-child { width: 54px; }
.history-skeleton .skeleton-line { width: 88%; height: 15px; margin-top: 10px; }
.history-skeleton .skeleton-line.short { width: 46%; height: 12px; }
.shorts-skeleton { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.shorts-skeleton-wrap { position: relative; width: min(100%, calc((100vh - var(--topbar) - 170px) * 9 / 16)); height: 100%; padding: 12px 0; }
.shorts-skeleton-screen { width: 100%; height: 100%; border-radius: 12px; }
.shorts-skeleton-rail { position: absolute; right: -62px; bottom: 120px; display: grid; gap: 16px; }
.skeleton-dot { width: 46px; height: 46px; border-radius: 50%; }
.shorts-skeleton-copy { position: absolute; right: 4px; bottom: 30px; left: 4px; display: grid; gap: 8px; }
/* 关于 YMB */
.about-page { max-width: 1080px; margin: 0 auto; padding: 8px 0 40px; counter-reset: about-section; }
/* 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: 12px; }
.store-badge { display: inline-flex; align-items: center; gap: 10px; height: 54px; padding: 0 20px 0 16px; border-radius: 12px; background: #000; color: #fff; box-shadow: inset 0 0 0 1px #ffffff47, 0 12px 28px #00000059; transition: transform .22s cubic-bezier(.34, 1.56, .64, 1), box-shadow .22s ease; }
.store-badge:hover { transform: translateY(-2px); box-shadow: inset 0 0 0 1px #ffffff73, 0 18px 36px #00000073; }
.store-badge svg { flex: none; }
.store-badge-copy { display: flex; flex-direction: column; line-height: 1.1; }
.store-badge-copy small { color: #cfcfcf; font-size: 9.5px; letter-spacing: .04em; }
.store-badge-copy b { color: #fff; font-size: 17px; letter-spacing: .01em; }
.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)); } }
@media (max-width: 1180px) { .create-button { width: 40px; height: 40px; padding: 0; border-radius: 50%; justify-content: center; } .create-button span { display: none; } }
@media (max-width: 1020px) and (min-width: 761px) {
.topbar { display: flex; column-gap: 12px; }
.topbar-left { flex: none; }
.topbar-center { flex: 1; }
.top-actions { margin-left: 0; }
}
@media (max-width: 1180px) and (min-width: 761px) { .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .shorts-row { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 760px) {
.topbar { display: flex; align-items: center; gap: 8px; height: var(--topbar); padding: 0 8px; }
.topbar-left { flex: 1; gap: 4px; }
.topbar-menu { width: 40px; height: 40px; }
.brand img { width: 74px; height: 28px; }
.topbar-center { display: contents; }
.create-button, .notification-button { display: none; }
.mobile-search { display: grid; width: 40px; height: 40px; border-radius: 50%; color: var(--text); }
.top-actions { gap: 6px; margin-left: auto; }
.language-switcher { display: block; }
.language-trigger { width: auto; height: 36px; padding: 0 10px; justify-content: center; gap: 0; }
.language-trigger .language-code { font-size: 12px; }
.search-form { display: none; }
.search-form.open { position: absolute; top: var(--topbar); left: 0; right: 0; z-index: 40; display: flex; max-width: none; height: auto; margin: 0; padding: 8px 12px; border-radius: 0; background: var(--bg); border-bottom: 1px solid #222; }
.search-form.open input { height: 40px; border-radius: 20px 0 0 20px; }
.search-form.open button { height: 40px; border-radius: 0 20px 20px 0; }
.content, .content-wide { padding: 0 12px calc(24px + env(safe-area-inset-bottom)); }
.youtube-category-bar { top: var(--topbar); margin: 0 -12px; padding: 10px 12px; gap: 8px; }
.youtube-category-chip { padding: 7px 12px; font-size: 13px; }
.video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 10px; }
.media-meta { gap: 8px; padding-top: 9px; }
.channel-avatar { width: 28px; height: 28px; font-size: 12px; }
.media-title { font-size: 14px; }
.media-meta small { font-size: 12px; }
.media-more { display: none; }
.youtube-shorts-shelf { margin: 20px -12px 32px; padding: 16px 12px 22px; }
.youtube-shelf-heading h2 { font-size: 20px; }
.shorts-row { display: flex; gap: 10px; overflow-x: auto; margin: 0; padding-bottom: 2px; }
.shorts-row .media-card { flex: 0 0 150px; }
.shorts-row .media-thumb { aspect-ratio: 9 / 16; }
.shorts-stage, .shorts-stage-wide { left: 0; bottom: 0; }
.short-slide { padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); background: #212121; }
.short-slide-stage { display: block; width: 100%; padding: 0; }
.short-column { display: block; height: 100%; }
.short-player-wrap { position: static; flex: none; height: 100%; }
.short-player-wrap::after { display: block; }
.short-slide-player { border-radius: 20px; box-shadow: 0 22px 54px #000a; }
.short-slide-info { position: absolute; left: 26px; right: 94px; bottom: 30px; width: auto; margin: 0; padding: 0; pointer-events: none; text-shadow: 0 2px 14px #000; }
.short-channel { gap: 8px; margin-bottom: 8px; }
.short-avatar { width: 32px; height: 32px; font-size: 13px; }
.short-channel-name { font-size: 13px; }
.short-follow { padding: 5px 12px; font-size: 11px; }
.short-rail { left: auto; right: 18px; bottom: 84px; gap: 12px; }
.short-rail .rail-icon { width: 42px; height: 42px; }
.short-rail small { font-size: 10px; }
.shorts-stage .feed-arrows { display: none; }
.shorts-toast { bottom: calc(20px + env(safe-area-inset-bottom)); }
.shorts-skeleton-wrap { width: 100%; height: 100%; padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); }
.shorts-skeleton-screen { border-radius: 14px; }
.shorts-skeleton-rail { right: 18px; bottom: 112px; gap: 12px; }
.skeleton-dot { width: 42px; height: 42px; }
.shorts-skeleton-copy { right: 94px; bottom: 30px; left: 26px; }
.comments-sheet { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: min(82vh, 640px); border-top: 1px solid #303030; border-left: 0; border-radius: 16px 16px 0 0; }
.comments-backdrop { top: 0; }
.upload-backdrop { padding: 12px; }
.upload-dialog { padding: 18px; }
.about-page { padding: 4px 0 16px; }
.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; }
.about-stores { margin-top: 22px; }
.about-stores-row { gap: 10px; }
.store-badge { height: 50px; padding: 0 16px 0 13px; gap: 8px; }
.store-badge-copy b { font-size: 15px; }
.about-values { margin-top: 20px; }
.about-section { margin-top: 32px; }
.about-section > h2 { font-size: 21px; }
.about-pillars { grid-template-columns: 1fr; gap: 12px; }
.about-pillar { padding: 20px; }
.about-cta { flex-direction: column; align-items: flex-start; padding: 24px 20px; }
.about-cta .about-primary { width: 100%; }
.short-slide-info h1 { font-size: 16px; }
.shorts-state { top: 12px; }
.watch-page { max-width: none; padding: 0; }
.watch-stage { margin: 0 2px; padding: 16px 10px; border-radius: 18px; }
.watch-player { height: min(62vh, 520px); border-radius: 16px; }
.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-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; }
.sidebar { display: none; top: var(--topbar); bottom: 0; z-index: 50; width: min(240px, calc(100vw - 56px)); padding: 12px; overflow-y: auto; background: var(--bg); box-shadow: 0 0 24px #000a; }
.sidebar.open { display: block; }
.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; display: flex; flex-direction: column-reverse; padding: 38px 14px 0; 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: flex-end; height: 14px; 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; bottom: 2px; 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: 0; padding-bottom: 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-sound-hint { position: absolute; top: 12px; left: 50%; z-index: 5; display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 0; border-radius: 999px; background: #000000b8; color: #fff; font-size: 12px; cursor: pointer; transform: translateX(-50%); backdrop-filter: blur(8px); box-shadow: 0 10px 26px #0008; }
.vp-sound-hint:hover { background: #000000d6; }
.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 8px 0; }
.vp-row { gap: 2px; padding: 0 2px 2px; }
.vp-row .vp-time, .vp-volume { display: none; }
.vp-progress { height: 12px; }
.vp-progress-track, .vp-progress:hover .vp-progress-track { height: 3px; border-radius: 0; }
.vp-thumb { display: none; }
.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; }
/* 进入播放页:从点击位置放大封面(高斯模糊)铺满,再淡出 */
.hero-transition { position: fixed; z-index: 1200; overflow: hidden; border-radius: 12px; pointer-events: none; transform-origin: top left; background: #09090b; box-shadow: 0 30px 80px #000a; animation: none; }
.hero-transition img { display: block; width: 100%; height: 100%; object-fit: cover; filter: blur(18px); transform: scale(1.14); }
.hero-transition::after { content: ''; position: absolute; inset: 0; background: radial-gradient(110% 110% at 50% 42%, #00000000 28%, #09090bcc 100%); }
/* 进入播放页:播放器轻微放大淡入 */
@keyframes ymb-player-in {
from { opacity: 0; transform: scale(.955); }
to { opacity: 1; transform: none; }
}
.watch-stage { animation: ymb-player-in .46s cubic-bezier(.2, .7, .3, 1) both; }
@media (prefers-reduced-motion: reduce) { .watch-stage { animation: none; } }
/* 页面动效:仅使用 transform / opacity,走合成层,不影响布局与滚动性能 */
@keyframes ymb-page-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@keyframes ymb-card-in {
from { opacity: 0; transform: translateY(14px) scale(.992); }
to { opacity: 1; transform: none; }
}
@keyframes ymb-shelf-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: none; }
}
.content > * { animation: ymb-page-in .3s cubic-bezier(.2, .7, .3, 1) both; }
.content > .hero-transition { animation: none; }
.video-grid .media-card,
.shorts-row .media-card { animation: ymb-card-in .34s cubic-bezier(.2, .7, .3, 1) both; }
.video-grid .media-card:nth-child(1) { animation-delay: .02s; }
.video-grid .media-card:nth-child(2) { animation-delay: .05s; }
.video-grid .media-card:nth-child(3) { animation-delay: .08s; }
.video-grid .media-card:nth-child(4) { animation-delay: .11s; }
.video-grid .media-card:nth-child(5) { animation-delay: .14s; }
.video-grid .media-card:nth-child(6) { animation-delay: .17s; }
.video-grid .media-card:nth-child(7) { animation-delay: .2s; }
.video-grid .media-card:nth-child(8) { animation-delay: .23s; }
.shorts-row .media-card:nth-child(1) { animation-delay: .04s; }
.shorts-row .media-card:nth-child(2) { animation-delay: .08s; }
.shorts-row .media-card:nth-child(3) { animation-delay: .12s; }
.shorts-row .media-card:nth-child(4) { animation-delay: .16s; }
.shorts-row .media-card:nth-child(5) { animation-delay: .2s; }
.media-thumb { position: relative; overflow: hidden; transition: box-shadow .25s ease; }
.media-thumb::after { content: ''; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 18px 42px #0008; opacity: 0; transition: opacity .25s ease; pointer-events: none; }
.media-card:hover .media-thumb::after { opacity: 1; }
.media-thumb img { transition: transform .45s cubic-bezier(.2, .7, .3, 1); }
.media-card:hover .media-thumb img { transform: scale(1.045); }
.media-thumb .thumb-play { transition: transform .3s cubic-bezier(.34, 1.56, .64, 1), opacity .3s ease; }
.media-card:hover .thumb-play { transform: scale(1.08); }
.youtube-category-chip { transition: background .18s ease, color .18s ease, transform .16s ease; }
.youtube-category-chip:active { transform: scale(.96); }
@media (hover: none) {
.media-card:hover .media-thumb img { transform: none; }
}
@supports (animation-timeline: view()) {
.youtube-home-feed > *,
.youtube-shorts-shelf {
animation: ymb-shelf-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 55%;
}
}
@media (prefers-reduced-motion: reduce) {
.content > *, .video-grid .media-card, .shorts-row .media-card,
.youtube-home-feed > *, .youtube-shorts-shelf { animation: none; }
.media-thumb img, .media-thumb::after, .thumb-play { transition: none; }
}
/* 全站文字统一不加粗 */
body,
body * { font-weight: 400 !important; }
b,
strong { font-weight: 400 !important; }
-108
View File
@@ -1,108 +0,0 @@
/* Author: fruit.com.cn */
:root {
--bg: #0f0f0f;
--surface: #181818;
--line: #303030;
--text: #f1f1f1;
--muted: #aaa;
--accent: #ff4b3e;
--sidebar: 224px;
}
body { background: var(--bg); color: var(--text); font-family: Arial, Helvetica, sans-serif; }
.topbar { height: 56px; gap: 16px; padding: 0 20px; background: #0f0f0ff2; border-bottom: 1px solid #222; box-shadow: none; }
.topbar-menu { color: var(--text); }
.topbar-menu:hover, .voice-search:hover, .notification-button:hover { background: #272727; }
.brand { min-width: 138px; height: 40px; }
.brand img { width: 104px; height: 38px; object-fit: contain; }
.search-form { height: 40px; max-width: 640px; flex: 1; margin: 0 0 0 clamp(18px, 7vw, 110px); border-radius: 22px; }
.search-form input { height: 40px; border-color: #3b3b3b; background: #121212; color: var(--text); }
.search-form button { height: 40px; background: #282828; color: var(--text); border-color: #3b3b3b; }
.voice-search { flex: none; width: 40px; height: 40px; border-radius: 50%; color: var(--text); }
.top-actions { gap: 14px; margin-left: auto; color: var(--text); }
.create-button { display: flex; align-items: center; gap: 6px; border: 0; border-radius: 22px; padding: 9px 14px; background: #272727; color: var(--text); font-weight: 600; cursor: pointer; }
.create-button:hover { background: #343434; }
.notification-button { width: 40px; height: 40px; border-radius: 50%; }
.language-trigger { border-color: #363636; background: #1d1d1d; color: #ddd; }
.profile-chip { background: #065fd4; color: #fff; }
.sidebar { top: 56px; width: var(--sidebar); padding: 12px 10px; background: var(--bg); border: 0; box-shadow: none; color: var(--text); }
.sidebar.closed { width: 72px; padding: 12px 8px; }
.sidebar nav a, .genre-link { min-height: 40px; padding: 9px 10px; border-radius: 10px; color: #ccc; gap: 18px; }
.sidebar nav a:hover, .genre-link:hover, .sidebar nav a.active { background: #272727; color: #fff; }
.sidebar-group { display: grid; gap: 2px; }
.sidebar-group-title { display: flex; align-items: center; gap: 7px; padding: 12px 10px 6px; font-size: 16px; font-weight: 700; }
.sidebar-group-title svg { color: #999; }
.sidebar small { display: none; }
.sidebar.closed .sidebar-group-title, .sidebar.closed .sidebar-label { display: none; }
.sidebar.closed nav a, .sidebar.closed .genre-link { justify-content: center; gap: 0; }
.sidebar.closed .nav-rule { display: block; margin: 12px 6px; }
.nav-rule { height: 1px; background: var(--line); margin: 12px 0; }
.content { max-width: none; padding: 12px 24px 80px 248px; margin: 0; }
.content-wide { padding-left: 84px; }
.youtube-home-header { display: none; }
.youtube-home-feed, .youtube-shorts-shelf { margin-bottom: 40px; }
.youtube-category-bar { position: sticky; top: 56px; z-index: 2; display: flex; gap: 12px; overflow-x: auto; padding: 12px 0; background: var(--bg); scrollbar-width: none; }
.youtube-category-bar::-webkit-scrollbar { display: none; }
.youtube-category-chip { flex: none; border: 0; border-radius: 8px; padding: 9px 14px; background: #2b2b2b; color: #fff; white-space: nowrap; cursor: pointer; font-size: 14px; }
.youtube-category-chip:hover { background: #343434; }
.youtube-category-chip.active { background: #fff; color: #111; }
.video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 36px 16px; }
.media-card { min-width: 0; }
.media-thumb { border-radius: 10px; background: #222; }
.media-thumb img { display: block; }
.media-meta { align-items: flex-start; gap: 12px; padding-top: 12px; }
.channel-avatar { width: 36px; height: 36px; background: #734c67; color: #fff; }
.media-copy { min-width: 0; flex: 1; }
.media-title { display: -webkit-box; overflow: hidden; color: #fff; font-size: 16px; line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.media-meta small { color: #999; font-size: 13px; line-height: 1.5; }
.media-more { flex: none; border: 0; background: transparent; color: #fff; padding: 4px; cursor: pointer; }
.youtube-shorts-shelf { padding: 20px 0 26px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.youtube-shelf-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.youtube-shelf-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; color: #fff; font-size: 24px; }
.youtube-shelf-heading button { border: 0; background: transparent; color: #ff8a82; font-weight: 600; cursor: pointer; }
.shorts-mark { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: #fff; font-size: 13px; }
.shorts-row { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; }
.shorts-row .media-thumb { aspect-ratio: 9 / 16; border-radius: 10px; }
.shorts-row .media-meta { display: none; }
.short-media-title { display: -webkit-box; overflow: hidden; margin-top: 9px; color: var(--text); font-size: 14px; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.language-menu { background: #222; border-color: #3b3b3b; box-shadow: 0 18px 45px #0009; }
.language-menu button { color: #ddd; }
.language-menu button:hover, .language-menu button.selected { background: #343434; color: #fff; }
@media (min-width: 1500px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 1100px) and (min-width: 761px) { .video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .shorts-row { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 760px) {
.topbar { height: 56px; padding: 0 12px; gap: 9px; }
.brand { min-width: 74px; }
.brand img { width: 74px; height: 34px; }
.voice-search, .create-button, .notification-button { display: none; }
.language-switcher { display: block; }
.language-trigger { width: 38px; height: 38px; justify-content: center; padding: 0; }
.language-trigger span, .language-trigger b, .language-trigger svg:last-child { display: none; }
.top-actions { gap: 8px; }
.search-form { position: absolute; top: 58px; left: 12px; right: 12px; max-width: none; margin: 0; }
.content, .content-wide { padding: 0 12px 88px; }
.youtube-category-bar { top: 56px; margin: 0 -12px; padding: 10px 12px; }
.video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 10px; }
.media-meta { gap: 8px; padding-top: 9px; }
.channel-avatar { width: 28px; height: 28px; font-size: 12px; }
.media-title { font-size: 13px; }
.media-meta small { font-size: 11px; }
.media-more { display: none; }
.youtube-shorts-shelf { margin: 28px -12px 34px; padding: 16px 12px 22px; }
.youtube-shelf-heading h2 { font-size: 20px; }
.shorts-row { display: flex; gap: 10px; overflow-x: auto; margin: 0; }
.shorts-row .media-card { flex: 0 0 150px; }
.shorts-row .media-thumb { aspect-ratio: 9 / 16; }
.youtube-home-feed-secondary { margin-top: 0; }
.bottom-nav { height: 64px; background: #151515; border-top: 1px solid #292929; }
.bottom-nav a { color: #aaa; }
.bottom-nav a.active { color: #fff; }
.sidebar { display: none; top: 56px; bottom: 64px; width: 246px; padding: 16px 12px; box-shadow: 16px 0 30px #0008; }
.sidebar.open { display: block; }
.sidebar.closed { display: none; }
.scrim { background: #0006; }
}
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more