mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-07 23:18:38 +08:00
chore: prepare project for open source release
- Remove internal planning docs (CLAUDE.md, docs/, feature.md, livePlan.md) from git tracking - Add .gitignore rules to prevent re-tracking internal files - Add README.md (Chinese) and README.en.md (English) with screenshots, features, quick start - Add LICENSE (MIT) - Add CONTRIBUTING.md with dev setup, commit conventions, PR guide - Add CHANGELOG.md for v1.0.0 - Add .github/ issue templates (bug report, feature request) and PR template - Add public/ screenshots (p1-p6)
This commit is contained in:
68
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
68
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Bug 报告
|
||||
description: 报告一个 Bug
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你提交 Bug 报告!请尽量填写完整信息,帮助我们更快定位问题。
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: 运行平台
|
||||
options:
|
||||
- Android(Dev Build)
|
||||
- Android(Expo Go)
|
||||
- iOS(Dev Build)
|
||||
- iOS(Expo Go)
|
||||
- Web
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: 问题分类
|
||||
options:
|
||||
- 视频播放
|
||||
- 直播
|
||||
- 弹幕
|
||||
- 登录 / 账号
|
||||
- 下载
|
||||
- UI / 界面
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
placeholder: 请描述你遇到的问题...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
placeholder: |
|
||||
1. 打开 App
|
||||
2. 点击 ...
|
||||
3. 发现 ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望行为
|
||||
placeholder: 你期望发生什么?
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 错误日志 / 截图
|
||||
description: 如有控制台报错或截图,请粘贴在此
|
||||
render: shell
|
||||
41
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
41
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: 功能建议
|
||||
description: 建议一个新功能或改进
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢你的建议!在提交前请先搜索现有 Issue,避免重复。
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 需求背景
|
||||
placeholder: 这个功能解决了什么问题?目前的痛点是什么?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 建议方案
|
||||
placeholder: 你期望的功能是怎样的?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 替代方案
|
||||
placeholder: 你是否考虑过其他方案?
|
||||
|
||||
- type: dropdown
|
||||
id: contribute
|
||||
attributes:
|
||||
label: 是否愿意提交 PR?
|
||||
options:
|
||||
- 是,我愿意实现这个功能
|
||||
- 否,希望维护者实现
|
||||
- 不确定
|
||||
validations:
|
||||
required: true
|
||||
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
## 改动说明
|
||||
|
||||
> 简要描述本次 PR 做了什么
|
||||
|
||||
## 改动类型
|
||||
|
||||
- [ ] Bug 修复
|
||||
- [ ] 新功能
|
||||
- [ ] 重构(不改变功能)
|
||||
- [ ] 文档更新
|
||||
- [ ] 其他:
|
||||
|
||||
## 关联 Issue
|
||||
|
||||
> 关闭 #(Issue 编号)
|
||||
|
||||
## 测试平台
|
||||
|
||||
- [ ] Android(Dev Build)
|
||||
- [ ] Android(Expo Go)
|
||||
- [ ] iOS
|
||||
- [ ] Web
|
||||
|
||||
## 截图 / 录屏(如适用)
|
||||
|
||||
## 注意事项
|
||||
|
||||
- [ ] 代码中无硬编码账号信息(SESSDATA、uid 等)
|
||||
- [ ] Commit 信息符合 Conventional Commits 规范
|
||||
- [ ] 已在本地测试通过
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -42,3 +42,9 @@ yarn-error.*
|
||||
|
||||
# git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Internal AI instructions & planning docs
|
||||
CLAUDE.md
|
||||
docs/
|
||||
feature.md
|
||||
livePlan.md
|
||||
|
||||
50
CHANGELOG.md
Normal file
50
CHANGELOG.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
所有重要更新都记录在此文件中。
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] — 2026-03-20
|
||||
|
||||
### 首个正式版本
|
||||
|
||||
#### 视频播放
|
||||
- DASH 完整播放:Bilibili DASH 接口 → `buildDashMpdUri()` 生成本地 MPD → ExoPlayer 原生解码
|
||||
- 支持多清晰度切换(360P / 480P / 720P / 1080P / 1080P+ / 4K)
|
||||
- BigVideoCard 首页内联 DASH 静音自动播放,支持水平手势快进、进度条/缓冲条
|
||||
- 全局迷你播放器(MiniPlayer),切换页面后底部浮层续播
|
||||
- WebView 降级方案(NativeVideoPlayer),兼容 Expo Go 环境
|
||||
|
||||
#### 直播
|
||||
- 直播 Tab 顶部显示关注主播在线状态
|
||||
- 双列直播卡片网格 + 横向分区筛选
|
||||
- 热门列表中穿插直播推荐卡片
|
||||
- LivePlayer 支持 HLS 多画质切换
|
||||
- 直播弹幕 WebSocket 实时接收,舰长标记 + 礼物计数
|
||||
|
||||
#### 弹幕系统
|
||||
- 视频弹幕:XML 全量拉取 + 时间轴同步 drip 渲染
|
||||
- DanmakuOverlay 飘屏覆盖层(5 车道滚动)
|
||||
- DanmakuList 支持实时直播模式(保留最近 500 条)
|
||||
|
||||
#### 搜索 & 内容
|
||||
- 视频关键词搜索 + 分页加载
|
||||
- 视频详情:简介 / 评论 / 弹幕 三 Tab
|
||||
- 推荐视频流(无限滚动)
|
||||
- 评论列表(热评 / 最新排序切换)
|
||||
|
||||
#### 账号 & 设置
|
||||
- 扫码登录(二维码 + 2s 轮询 + SESSDATA 自动提取)
|
||||
- 登录态持久化(AsyncStorage)
|
||||
- 封面图清晰度设置(高清 / 普通,节省流量)
|
||||
|
||||
#### 下载 & 分享
|
||||
- 多清晰度视频后台下载
|
||||
- 下载管理页(播放、删除已下载视频)
|
||||
- 局域网 HTTP 服务器,生成 QR 码分享,同 Wi-Fi 设备扫码直接播放
|
||||
|
||||
#### 跨平台
|
||||
- Android、iOS、Web 三端支持
|
||||
- Expo Go 扫码快速运行(UI 预览模式)
|
||||
- Dev Build 完整功能(DASH 原生播放)
|
||||
226
CLAUDE.md
226
CLAUDE.md
@@ -1,226 +0,0 @@
|
||||
# reactBilibiliApp — 项目架构与技术要点
|
||||
|
||||
## 项目概述
|
||||
仿 B 站 React Native 客户端,使用 Expo SDK 55 + expo-router,调用 Bilibili 官方 Web API 获取热门视频、直播、视频详情、弹幕、评论、搜索及扫码登录。
|
||||
|
||||
## 技术栈
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 框架 | React Native 0.83 + Expo SDK 55 |
|
||||
| 路由 | expo-router v4(文件系统路由,Stack 导航) |
|
||||
| 状态管理 | Zustand |
|
||||
| 网络请求 | Axios |
|
||||
| 本地存储 | @react-native-async-storage/async-storage |
|
||||
| 视频播放(详情页/直播) | react-native-video(DASH/HLS/MP4)|
|
||||
| 视频播放(首页 BigCard) | react-native-video(DASH MPD,内联自动播放) |
|
||||
| 视频播放(WebView 降级) | react-native-webview(NativeVideoPlayer) |
|
||||
| 页面滑动 | react-native-pager-view(首页热门/直播 Tab) |
|
||||
| 图标 | @expo/vector-icons(Ionicons) |
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
app/
|
||||
_layout.tsx # 根布局:Stack 导航 + 启动时恢复登录态/下载列表/设置
|
||||
# 全局挂载 <MiniPlayer />
|
||||
index.tsx # 首页:PagerView 切换热门/直播;绝对定位悬浮导航栏
|
||||
video/
|
||||
_layout.tsx # Stack 导航(无头部)
|
||||
[bvid].tsx # 视频详情页(播放 + 简介/评论/弹幕 三 Tab + 推荐视频 + 下载)
|
||||
live/
|
||||
_layout.tsx # Stack 导航(无头部)
|
||||
[roomId].tsx # 直播详情页(HLS/FLV 播放 + 简介/弹幕 Tab)
|
||||
search.tsx # 搜索页(关键词搜索视频)
|
||||
downloads.tsx # 下载管理页(已下载视频列表)
|
||||
settings.tsx # 设置页(封面图清晰度 + 退出登录)
|
||||
|
||||
components/
|
||||
VideoCard.tsx # 双列视频卡片(封面、标题、UP主、播放量);读 settingsStore 决定图片质量
|
||||
BigVideoCard.tsx # 全宽视频卡片(首页热门精选),内联 react-native-video 自动播放,
|
||||
# 支持静音/取消静音、水平滑动快进、进度条/缓冲条
|
||||
LiveCard.tsx # 直播房间卡片(封面、主播、在线人数、直播状态脉冲点)
|
||||
LivePulse.tsx # 直播红点动画组件
|
||||
VideoPlayer.tsx # 视频播放器入口:web → <video>,native → react-native-video (DASH/MP4)
|
||||
# 支持多清晰度切换
|
||||
NativeVideoPlayer.tsx# WebView 内嵌 HTML5 video,JS 注入 src(降级方案)
|
||||
LivePlayer.tsx # 直播播放器(react-native-video HLS,多画质切换)
|
||||
DanmakuList.tsx # 弹幕列表面板(视频/直播通用);支持逐行逐帧 drip 渲染、
|
||||
# 舰长标记、礼物计数、实时直播弹幕追加
|
||||
DanmakuOverlay.tsx # 悬浮弹幕层(飘屏弹幕,用于视频播放器覆盖层)
|
||||
MiniPlayer.tsx # 全局迷你播放器(底部悬浮,切页后继续播放)
|
||||
CommentItem.tsx # 评论条目
|
||||
LoginModal.tsx # 扫码登录 Modal(底部弹出)
|
||||
DownloadSheet.tsx # 下载选择面板(选清晰度后触发下载)
|
||||
DownloadProgressBtn.tsx # 导航栏下载进度按钮(显示正在下载数量)
|
||||
|
||||
hooks/
|
||||
useVideoList.ts # 热门视频 + 首页直播轮播分页加载(下拉刷新、上拉加载更多)
|
||||
useLiveList.ts # 直播列表分页加载(按分区过滤)
|
||||
useVideoDetail.ts # 视频详情 + 获取播放流 URL(支持多清晰度 DASH/MP4)
|
||||
useLiveDetail.ts # 直播间详情 + HLS/FLV 流地址(支持画质切换)
|
||||
useLiveDanmaku.ts # 直播弹幕 WebSocket 实时接收
|
||||
useComments.ts # 评论分页加载(热评/最新排序)
|
||||
useRelatedVideos.ts # 推荐视频流(视频详情页简介 Tab 下方)
|
||||
useSearch.ts # 搜索钩子(分页加载搜索结果)
|
||||
useDownload.ts # 下载任务管理
|
||||
|
||||
services/
|
||||
bilibili.ts # 所有 API 请求(axios 实例 + Cookie 拦截器)
|
||||
types.ts # 数据类型定义
|
||||
|
||||
store/
|
||||
authStore.ts # Zustand 登录状态(sessdata/uid/username/face + 持久化)
|
||||
downloadStore.ts # Zustand 下载任务列表(持久化)
|
||||
settingsStore.ts # Zustand 用户设置(coverQuality: 'hd'|'normal' + 持久化)
|
||||
videoStore.ts # Zustand 当前播放视频状态(供 MiniPlayer 使用)
|
||||
|
||||
utils/
|
||||
format.ts # formatCount / formatDuration / formatTime
|
||||
imageUrl.ts # proxyImageUrl(Web 代理绕防盗链)/ coverImageUrl(清晰度参数)
|
||||
dash.ts # buildDashMpdUri(将 B 站 DASH 接口数据转换为 MPD 文件 URI)
|
||||
danmaku.ts # danmakuColorToCss(弹幕颜色转换)
|
||||
```
|
||||
|
||||
## 路由结构
|
||||
- `/` → 首页(Stack 根页面,PagerView 热门/直播 Tab)
|
||||
- `/video/[bvid]` → 视频详情(Stack push,slide_from_right 动画)
|
||||
- `/live/[roomId]` → 直播详情(Stack push,slide_from_right 动画)
|
||||
- `/search` → 搜索页(Stack push)
|
||||
- `/downloads` → 下载管理(Stack push)
|
||||
- `/settings` → 设置(Stack push,已登录时从头像按钮进入)
|
||||
|
||||
## 首页结构(index.tsx)
|
||||
- **PagerView** 容纳热门列表(page 0)和直播列表(page 1),`scrollEnabled: false`
|
||||
- **绝对定位悬浮导航栏**:Header(头像/搜索框/下载按钮)+ Tab 行(热门/直播),随内容滚动收起
|
||||
- **热门列表**:`toListRows()` 将视频分为 `big`(全宽 BigVideoCard)、`live`(直播推荐)、`pair`(双列 VideoCard)三种行类型
|
||||
- **直播列表**:横向分区过滤 ScrollView + 双列 LiveCard 网格,支持按分区 ID 切换
|
||||
- 点击已激活 Tab 会滚动到顶并刷新数据
|
||||
|
||||
## BigVideoCard 内联视频(components/BigVideoCard.tsx)
|
||||
- 使用 `react-native-video` 直接播放 DASH(fnval=16,buildDashMpdUri 转 MPD)
|
||||
- 可见时自动播放(静音),不可见时暂停并恢复封面
|
||||
- 水平滑动手势(PanResponder)实现快进/快退,显示时间标签
|
||||
- 进度条 + 缓冲条;静音/取消静音按钮
|
||||
- 封面图质量受 `settingsStore.coverQuality` 控制
|
||||
|
||||
## Bilibili API 关键点
|
||||
|
||||
### axios 实例(services/bilibili.ts)
|
||||
- 统一携带 `Referer: https://www.bilibili.com`、`User-Agent`(模拟 Chrome)
|
||||
- 请求拦截器自动注入 Cookie:`buvid3`(随机生成并持久化)+ `SESSDATA`(登录后)
|
||||
|
||||
### 主要接口
|
||||
| 函数 | 接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `getPopularVideos(pn)` | `/x/web-interface/popular` | 热门视频,每页 20 条 |
|
||||
| `getVideoDetail(bvid)` | `/x/web-interface/view` | 视频详情(含 ugc_season 合集) |
|
||||
| `getPlayUrl(bvid, cid, fnval)` | `/x/player/playurl` | 获取播放流(fnval=16 DASH,fnval=0 MP4) |
|
||||
| `getRecommendFeed(idx)` | `/x/web-interface/index/top/rcmd` | 推荐视频流(详情页下方) |
|
||||
| `getComments(aid, pn, sort)` | `/x/v2/reply` | 评论列表(sort=2 热评,sort=0 最新) |
|
||||
| `getDanmaku(cid)` | `/x/v1/dm/list.so` | 弹幕列表(XML 格式,返回 DanmakuItem[]) |
|
||||
| `getLiveRoomList(areaId, page)` | live 域名 | 直播列表(按分区) |
|
||||
| `getLiveDetail(roomId)` | live 域名 | 直播间详情 + 主播信息 |
|
||||
| `getLiveStream(roomId, qn)` | live 域名 | HLS/FLV 直播流地址 |
|
||||
| `searchVideos(keyword, pn)` | `/x/web-interface/search/all/v2` | 视频搜索 |
|
||||
| `generateQRCode()` | passport 域名 | 生成登录二维码 |
|
||||
| `pollQRCode(key)` | passport 域名 | 轮询扫码状态(2s 间隔) |
|
||||
|
||||
### 播放流参数(重要)
|
||||
```ts
|
||||
// DASH(react-native-video,dev build / 生产包)
|
||||
params: { bvid, cid, qn: 112, fnval: 16, platform: 'pc' }
|
||||
// → 返回 dash 字段,需 buildDashMpdUri 转为本地 MPD
|
||||
|
||||
// MP4(WebView 降级 / Expo Go)
|
||||
params: { bvid, cid, qn: 64, fnval: 0, platform: 'html5' }
|
||||
// → 返回 durl[0].url (MP4)
|
||||
```
|
||||
- `fnval: 16` → DASH,`dash.video[]` + `dash.audio[]`,由 `buildDashMpdUri` 生成 MPD
|
||||
- `fnval: 0` + `platform: 'html5'` → MP4(`durl[0].url`)
|
||||
- `fnval: 1` → FLV,不可用(HTML5/WebView 不支持)
|
||||
- `qn: 112` = 1080P+,`qn: 80` = 1080P,`qn: 64` = 720P,未登录时 B 站可能降级
|
||||
|
||||
## 视频播放架构
|
||||
|
||||
### 视频详情页(VideoPlayer)
|
||||
```
|
||||
VideoPlayer
|
||||
├── web (Platform.OS === 'web') → 原生 <video> 标签
|
||||
└── native → react-native-video (DASH MPD / MP4)
|
||||
支持多清晰度切换、弹幕覆盖层(DanmakuOverlay)
|
||||
降级:NativeVideoPlayer (WebView + JS 注入)
|
||||
```
|
||||
|
||||
### 首页 BigVideoCard
|
||||
```
|
||||
BigVideoCard
|
||||
└── react-native-video(DASH MPD,静音自动播放)
|
||||
+ PanResponder 手势快进
|
||||
+ 封面 Animated.View 淡出过渡
|
||||
```
|
||||
|
||||
### 直播(LivePlayer)
|
||||
```
|
||||
LivePlayer
|
||||
└── react-native-video(HLS m3u8 流)
|
||||
支持画质切换(触发重新获取流地址)
|
||||
```
|
||||
|
||||
## 弹幕系统
|
||||
- **视频弹幕**:`getDanmaku(cid)` 获取全量弹幕,按时间戳 drip 渲染到 `DanmakuList`
|
||||
- **直播弹幕**:`useLiveDanmaku(roomId)` 通过 WebSocket 实时接收,支持礼物计数(giftCounts)
|
||||
- **弹幕覆盖**:`DanmakuOverlay` 悬浮于播放器之上,飘屏滚动
|
||||
- DanmakuList 支持 `isLive` 模式(实时追加新弹幕,保留最近 500 条)
|
||||
|
||||
## 下载功能
|
||||
- `DownloadSheet`:选择清晰度后调用 `useDownload` 触发后台下载
|
||||
- `downloadStore`:持久化下载任务列表(进度、状态、本地路径)
|
||||
- `DownloadProgressBtn`:导航栏图标,显示正在下载的任务数量
|
||||
- `downloads.tsx`:下载管理页,列出已完成/进行中任务,支持播放和删除
|
||||
|
||||
## 登录流程
|
||||
1. 未登录时右上角头像图标 → 弹出 `LoginModal`;已登录时跳转 `/settings`
|
||||
2. 调用 `generateQRCode()` 获取 `qrcode_key` + 二维码 URL
|
||||
3. 用 `https://api.qrserver.com` 渲染二维码图片(第三方服务)
|
||||
4. 每 2s 轮询 `pollQRCode`,`code === 0` 时从响应 Header `set-cookie` 提取 `SESSDATA`
|
||||
5. `useAuthStore.login()` 将 `SESSDATA` 写入 AsyncStorage 并更新 Zustand 状态
|
||||
6. 启动时 `_layout.tsx` 调用 `restore()` 恢复登录态、下载列表、设置
|
||||
|
||||
## 设置(settings.tsx / settingsStore)
|
||||
- `coverQuality: 'hd' | 'normal'`:控制封面图清晰度
|
||||
- `hd`:原始 URL,不加参数
|
||||
- `normal`:追加 `@320w_180h_1c.webp` 缩略图参数,节省流量
|
||||
- VideoCard 和 BigVideoCard 均从 `settingsStore` 读取此值
|
||||
- 退出登录功能(调用 `authStore.logout()`)
|
||||
|
||||
## imageUrl 工具
|
||||
```ts
|
||||
proxyImageUrl(url) // Web 端转本地代理 localhost:3001/bilibili-img/...(绕防盗链)
|
||||
// Native 端将 http:// 强制改为 https://
|
||||
coverImageUrl(url, quality) // 在 proxyImageUrl 基础上附加清晰度参数
|
||||
```
|
||||
|
||||
## 主题色
|
||||
- 主色:`#00AEEC`(B 站蓝)
|
||||
- 文字主色:`#212121`
|
||||
- 次要文字:`#999`
|
||||
- 背景:`#f4f4f4`(列表)/ `#fff`(卡片)
|
||||
- 危险色(退出登录等):`#ff4757`
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
### 运行方式
|
||||
- Dev Build(推荐):`expo run:android`,支持 react-native-video(DASH/HLS)
|
||||
- Expo Go:`expo start` 扫码,react-native-video 可用但部分编解码受限,降级用 WebView 方案
|
||||
|
||||
### 常见问题
|
||||
- **视频无法播放**:检查 `fnval`;DASH 需确认 `buildDashMpdUri` 生成的 MPD 合法
|
||||
- **API 403**:`buvid3` Cookie 或 `SESSDATA` 失效,清除 AsyncStorage 重试
|
||||
- **评论为空**:B 站部分视频评论区关闭,`replies` 字段为 null 属正常
|
||||
- **二维码过期**:`pollQRCode` 返回 `code === 86038`,关闭 Modal 重新打开即可
|
||||
- **直播无法播放**:确认 HLS URL 有效;部分直播间仅提供 FLV,需选 FLV 流
|
||||
- **封面加载慢**:在设置中切换为「普通」清晰度可减小图片体积
|
||||
|
||||
### 扩展方向
|
||||
- 动态流:需更高权限 Cookie(`bili_jct` CSRF Token)
|
||||
- 投稿/点赞/收藏:需登录态 + CSRF
|
||||
- 离线弹幕导出:DanmakuItem 已有完整数据结构
|
||||
96
CONTRIBUTING.md
Normal file
96
CONTRIBUTING.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 贡献指南 · Contributing Guide
|
||||
|
||||
感谢你考虑为 JKVideo 做贡献!
|
||||
|
||||
---
|
||||
|
||||
## 开发环境搭建
|
||||
|
||||
### 推荐方式:Dev Build(完整功能)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/你的用户名/JKVideo.git
|
||||
cd JKVideo
|
||||
npm install
|
||||
npx expo run:android # 需要连接 Android 设备或启动模拟器
|
||||
```
|
||||
|
||||
Dev Build 支持 DASH 原生播放(react-native-video),是开发视频播放功能的必选方式。
|
||||
|
||||
### Expo Go 模式(快速验证 UI)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
适合开发 UI 组件、导航、弹幕列表等不依赖原生视频解码的功能。
|
||||
|
||||
### Web 端
|
||||
|
||||
```bash
|
||||
npx expo start --web
|
||||
```
|
||||
|
||||
Web 端图片防盗链需要本地代理,在单独终端启动:
|
||||
|
||||
```bash
|
||||
node scripts/proxy.js # 监听 localhost:3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 提交规范(Conventional Commits)
|
||||
|
||||
请遵循以下格式:
|
||||
|
||||
```
|
||||
<type>(<scope>): <描述>
|
||||
|
||||
[可选正文]
|
||||
```
|
||||
|
||||
| type | 含义 |
|
||||
|---|---|
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | Bug 修复 |
|
||||
| `refactor` | 重构(不改变功能) |
|
||||
| `docs` | 文档更新 |
|
||||
| `chore` | 构建脚本、依赖更新等 |
|
||||
| `style` | 代码格式(不影响逻辑) |
|
||||
| `perf` | 性能优化 |
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
feat(danmaku): 添加弹幕字体大小设置
|
||||
fix(player): 修复 DASH MPD 解析在 Android 12 上崩溃的问题
|
||||
docs: 更新 README 快速开始步骤
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PR 提交流程
|
||||
|
||||
1. Fork 本仓库并创建功能分支:`git checkout -b feat/your-feature`
|
||||
2. 在本地完成开发并测试
|
||||
3. 提交符合规范的 commit
|
||||
4. 发起 Pull Request,填写模板中的说明
|
||||
5. 等待 Code Review
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **禁止**在代码中硬编码任何账号信息(SESSDATA、uid 等)
|
||||
- **不接受**涉及自动化批量操作、绕过 B 站反爬的 PR
|
||||
- 新增功能请优先开 Issue 讨论,避免重复劳动
|
||||
- 涉及 API 参数变更时,请同步更新 `services/bilibili.ts` 中的注释
|
||||
|
||||
---
|
||||
|
||||
## 问题反馈
|
||||
|
||||
- Bug 报告:[提交 Issue](https://github.com/你的用户名/JKVideo/issues/new?template=bug_report.yml)
|
||||
- 功能建议:[提交 Feature Request](https://github.com/你的用户名/JKVideo/issues/new?template=feature_request.yml)
|
||||
- 使用问题:优先在 [Discussions](https://github.com/你的用户名/JKVideo/discussions) 提问
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 JKVideo Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
184
README.en.md
Normal file
184
README.en.md
Normal file
@@ -0,0 +1,184 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://img.shields.io/badge/JKVideo-Bilibili_Client-00AEEC?style=for-the-badge&logo=bilibili&logoColor=white" alt="JKVideo"/>
|
||||
|
||||
# JKVideo
|
||||
|
||||
**A feature-rich third-party Bilibili React Native client**
|
||||
|
||||
*DASH playback · Real-time danmaku · WBI signing · Live streaming · Cross-platform*
|
||||
|
||||
---
|
||||
|
||||
[](https://reactnative.dev)
|
||||
[](https://expo.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
[](README.en.md)
|
||||
|
||||
[中文](README.md) · [Quick Start](#quick-start) · [Features](#features) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="public/p1.jpg" width="180"/><br/><sub>Home · Inline Video · Live Cards</sub></td>
|
||||
<td align="center"><img src="public/p2.jpg" width="180"/><br/><sub>Video Detail · Info · Recommendations</sub></td>
|
||||
<td align="center"><img src="public/p3.jpg" width="180"/><br/><sub>Player · 4K HDR · Quality Switch</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="public/p4.jpg" width="180"/><br/><sub>Downloads · LAN Share QR Code</sub></td>
|
||||
<td align="center"><img src="public/p5.jpg" width="180"/><br/><sub>Live Tab · Followed Streamers · Categories</sub></td>
|
||||
<td align="center"><img src="public/p6.jpg" width="180"/><br/><sub>Live Room · Real-time Danmaku · Guard Marks</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
🎬 **Full DASH Playback**
|
||||
Bilibili DASH stream → `buildDashMpdUri()` local MPD → ExoPlayer native decode, supports 1080P+ & 4K HDR
|
||||
|
||||
💬 **Complete Danmaku System**
|
||||
Video danmaku with XML timeline sync + 5-lane floating overlay; Live danmaku via WebSocket with guard marks & gift counting
|
||||
|
||||
🔐 **WBI Signing**
|
||||
Pure TypeScript MD5 implementation, zero external crypto dependencies, 12h auto-cached nav interface
|
||||
|
||||
🏠 **Smart Home Layout**
|
||||
BigVideoCard inline DASH muted autoplay + swipe-to-seek gesture + live card interleaving + dual-column grid
|
||||
|
||||
📺 **Global Mini Player**
|
||||
Persistent bottom overlay player survives navigation, VideoStore cross-component state sync
|
||||
|
||||
🔑 **QR Code Login**
|
||||
QR code generation + 2s polling + automatic SESSDATA extraction from response headers
|
||||
|
||||
📥 **Download + LAN Sharing**
|
||||
Multi-quality background download, built-in HTTP server generates LAN QR code for same-Wi-Fi playback
|
||||
|
||||
🌐 **Cross-Platform**
|
||||
Android · iOS · Web, Expo Go scan-to-run in 5 minutes, Dev Build unlocks full DASH playback
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Framework | React Native 0.83 + Expo SDK 55 |
|
||||
| Navigation | expo-router v4 (file-based, Stack) |
|
||||
| State | Zustand |
|
||||
| HTTP | Axios |
|
||||
| Storage | @react-native-async-storage/async-storage |
|
||||
| Video | react-native-video (DASH MPD / HLS / MP4) |
|
||||
| Fallback | react-native-webview (HTML5 video injection) |
|
||||
| Pager | react-native-pager-view |
|
||||
| Icons | @expo/vector-icons (Ionicons) |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Expo Go (5 minutes, no build required)
|
||||
|
||||
> Some quality options limited; video falls back to WebView
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/JKVideo.git
|
||||
cd JKVideo
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Scan the QR code with [Expo Go](https://expo.dev/go) on Android or iOS.
|
||||
|
||||
### Option 2: Dev Build (Full features, recommended)
|
||||
|
||||
> Supports DASH 1080P+ native playback, full danmaku system
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo run:android # Android
|
||||
npx expo run:ios # iOS (requires macOS + Xcode)
|
||||
```
|
||||
|
||||
### Option 3: Web
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start --web
|
||||
```
|
||||
|
||||
> Web requires a local proxy server for image anti-hotlinking: `node scripts/proxy.js` (port 3001)
|
||||
|
||||
### Direct Install (Android)
|
||||
|
||||
Download the latest APK from [Releases](https://github.com/your-username/JKVideo/releases/latest) — no build needed.
|
||||
|
||||
> Enable "Install from unknown sources" in Android settings
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
index.tsx # Home (PagerView Hot/Live tabs)
|
||||
video/[bvid].tsx # Video detail (player + info/comments/danmaku)
|
||||
live/[roomId].tsx # Live room (HLS player + real-time danmaku)
|
||||
search.tsx # Search page
|
||||
downloads.tsx # Download manager
|
||||
settings.tsx # Settings (quality + logout)
|
||||
|
||||
components/ # UI components (player, danmaku, cards, etc.)
|
||||
hooks/ # Data hooks (video list, stream URLs, danmaku, etc.)
|
||||
services/ # Bilibili API wrapper (axios + cookie interceptor)
|
||||
store/ # Zustand stores (auth, download, playback, settings)
|
||||
utils/ # Utilities (format, image proxy, MPD builder)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
| Limitation | Reason |
|
||||
|---|---|
|
||||
| 4K / 1080P+ requires premium account | Bilibili API restriction |
|
||||
| FLV live streams not supported | Neither HTML5 nor ExoPlayer support FLV; HLS auto-selected |
|
||||
| Web requires local proxy | Bilibili image Referer anti-hotlinking |
|
||||
| Feed / like / collect features | Requires `bili_jct` CSRF token, not yet implemented |
|
||||
| QR code expires after 10 minutes | Close and reopen the login modal to refresh |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is for personal learning and research purposes only. Not for commercial use.
|
||||
All video content copyright belongs to the original authors and Bilibili.
|
||||
This project is not affiliated with Bilibili in any way.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © 2026 JKVideo Contributors
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
If this project helps you, please give it a ⭐ Star!
|
||||
|
||||
</div>
|
||||
184
README.md
Normal file
184
README.md
Normal file
@@ -0,0 +1,184 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://img.shields.io/badge/JKVideo-仿B站客户端-00AEEC?style=for-the-badge&logo=bilibili&logoColor=white" alt="JKVideo"/>
|
||||
|
||||
# JKVideo
|
||||
|
||||
**高颜值第三方 B 站 React Native 客户端**
|
||||
|
||||
*A feature-rich Bilibili-like app with DASH playback, real-time danmaku, WBI signing & live streaming*
|
||||
|
||||
---
|
||||
|
||||
[](https://reactnative.dev)
|
||||
[](https://expo.dev)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
[](README.md)
|
||||
|
||||
[English](README.en.md) · [快速开始](#快速开始) · [功能亮点](#功能亮点) · [贡献](CONTRIBUTING.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 截图预览
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="public/p1.jpg" width="180"/><br/><sub>首页热门 · 内联视频 · 穿插直播</sub></td>
|
||||
<td align="center"><img src="public/p2.jpg" width="180"/><br/><sub>视频详情 · 简介 · 推荐视频</sub></td>
|
||||
<td align="center"><img src="public/p3.jpg" width="180"/><br/><sub>竖屏播放 · 4K HDR · 多清晰度</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="public/p4.jpg" width="180"/><br/><sub>下载管理 · 局域网分享二维码</sub></td>
|
||||
<td align="center"><img src="public/p5.jpg" width="180"/><br/><sub>直播 Tab · 关注主播在线 · 分区筛选</sub></td>
|
||||
<td align="center"><img src="public/p6.jpg" width="180"/><br/><sub>直播详情 · 实时弹幕 · 舰长标记</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 功能亮点
|
||||
|
||||
🎬 **DASH 完整播放**
|
||||
Bilibili DASH 流 → `buildDashMpdUri()` 生成本地 MPD → ExoPlayer 原生解码,支持 1080P+ 4K HDR
|
||||
|
||||
💬 **完整弹幕系统**
|
||||
视频弹幕 XML 时间轴同步 + 5 车道飘屏覆盖;直播弹幕 WebSocket 实时接收 + 舰长标记 + 礼物计数
|
||||
|
||||
🔐 **WBI 签名实现**
|
||||
纯 TypeScript 手写 MD5,无任何外部加密依赖,nav 接口 12h 自动缓存
|
||||
|
||||
🏠 **智能首页排布**
|
||||
BigVideoCard 内联 DASH 静音自动播放 + 水平手势快进 + 直播卡片穿插 + 双列混排
|
||||
|
||||
📺 **全局迷你播放器**
|
||||
切换页面后底部浮层续播,VideoStore 跨组件状态同步
|
||||
|
||||
🔑 **扫码登录**
|
||||
二维码生成 + 2s 轮询 + 响应头 Cookie 自动提取 SESSDATA
|
||||
|
||||
📥 **下载 + 局域网分享**
|
||||
多清晰度后台下载,内置 HTTP 服务器生成局域网 QR 码,同 Wi-Fi 设备扫码直接播放
|
||||
|
||||
🌐 **跨平台运行**
|
||||
Android · iOS · Web,Expo Go 扫码 5 分钟运行,Dev Build 解锁完整 DASH 播放
|
||||
|
||||
---
|
||||
|
||||
## 技术架构
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 框架 | React Native 0.83 + Expo SDK 55 |
|
||||
| 路由 | expo-router v4(文件系统路由,Stack 导航) |
|
||||
| 状态管理 | Zustand |
|
||||
| 网络请求 | Axios |
|
||||
| 本地存储 | @react-native-async-storage/async-storage |
|
||||
| 视频播放 | react-native-video(DASH MPD / HLS / MP4) |
|
||||
| 降级播放 | react-native-webview(HTML5 video 注入) |
|
||||
| 页面滑动 | react-native-pager-view |
|
||||
| 图标 | @expo/vector-icons(Ionicons) |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:Expo Go(5 分钟,无需编译)
|
||||
|
||||
> 部分清晰度受限,视频播放降级为 WebView 方案
|
||||
|
||||
```bash
|
||||
git clone https://github.com/你的用户名/JKVideo.git
|
||||
cd JKVideo
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
用 Expo Go App([Android](https://expo.dev/go) / [iOS](https://expo.dev/go))扫描终端二维码即可运行。
|
||||
|
||||
### 方式二:Dev Build(完整功能,推荐)
|
||||
|
||||
> 支持 DASH 1080P+ 原生播放、完整弹幕系统
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo run:android # Android
|
||||
npx expo run:ios # iOS(需 macOS + Xcode)
|
||||
```
|
||||
|
||||
### 方式三:Web 端
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx expo start --web
|
||||
```
|
||||
|
||||
> Web 端图片需本地代理服务器绕过防盗链:`node scripts/proxy.js`(端口 3001)
|
||||
|
||||
### 直接安装(Android)
|
||||
|
||||
前往 [Releases](https://github.com/你的用户名/JKVideo/releases/latest) 下载最新 APK,无需编译,安装即用。
|
||||
|
||||
> 需在 Android 设置中开启「安装未知来源应用」
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
app/
|
||||
index.tsx # 首页(PagerView 热门/直播 Tab)
|
||||
video/[bvid].tsx # 视频详情(播放 + 简介/评论/弹幕)
|
||||
live/[roomId].tsx # 直播详情(HLS 播放 + 实时弹幕)
|
||||
search.tsx # 搜索页
|
||||
downloads.tsx # 下载管理页
|
||||
settings.tsx # 设置页(画质 + 退出登录)
|
||||
|
||||
components/ # UI 组件(播放器、弹幕、卡片等)
|
||||
hooks/ # 数据 Hooks(视频列表、播放流、弹幕等)
|
||||
services/ # Bilibili API 封装(axios + Cookie 拦截)
|
||||
store/ # Zustand 状态(登录、下载、播放、设置)
|
||||
utils/ # 工具函数(格式化、图片代理、MPD 构建)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
| 限制 | 原因 |
|
||||
|---|---|
|
||||
| 4K / 1080P+ 需要大会员账号登录 | B 站 API 策略限制 |
|
||||
| FLV 直播流不支持 | HTML5 / ExoPlayer 均不支持 FLV,已自动选 HLS |
|
||||
| Web 端需本地代理 | B 站图片防盗链(Referer 限制) |
|
||||
| 动态流 / 投稿 / 点赞 | 需要 `bili_jct` CSRF Token,暂未实现 |
|
||||
| 二维码 10 分钟过期 | 关闭登录弹窗重新打开即可刷新 |
|
||||
|
||||
---
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 PR!请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅供个人学习研究使用,不得用于商业用途。
|
||||
所有视频内容版权归原作者及哔哩哔哩所有。
|
||||
本项目与哔哩哔哩官方无任何关联。
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © 2026 JKVideo Contributors
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
如果这个项目对你有帮助,欢迎点一个 ⭐ Star!
|
||||
|
||||
</div>
|
||||
@@ -1,218 +0,0 @@
|
||||
# B站直播弹幕连接 — 学习笔记
|
||||
|
||||
> 参考项目:https://github.com/Minteea/bilibili-live-danmaku (v0.7.15)
|
||||
|
||||
---
|
||||
|
||||
## 一、完整连接流程
|
||||
|
||||
```
|
||||
1. 初始化 Cookie
|
||||
GET https://www.bilibili.com/ → 种植初始 cookie
|
||||
POST .../bilibili.api.ticket.v1.Ticket/GenWebTicket → 获取 bili_ticket + WBI 签名密钥
|
||||
GET .../x/frontend/finger/spi → 获取 buvid4
|
||||
POST .../x/internal/gaia-gateway/ExClimbWuzhi → 激活 buvid(模拟指纹)
|
||||
|
||||
2. 获取弹幕服务器信息(需要 WBI 签名)
|
||||
GET https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={roomId}
|
||||
响应:{ token, host_list: [{ host, wss_port, ws_port }] }
|
||||
|
||||
3. 建立 WebSocket 连接
|
||||
wss://{host_list[0].host}/sub
|
||||
fallback: wss://broadcastlv.chat.bilibili.com/sub
|
||||
|
||||
4. onopen → 发送 AUTH 包(op=7)
|
||||
5. 收到 op=8 (CONNECT_SUCCESS) → 立即发送一次心跳(op=2)
|
||||
6. 每次收到 op=3 (HEARTBEAT_REPLY) → 30秒后发下一次心跳
|
||||
7. 收到 op=5 (MESSAGE) → 解析弹幕数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、二进制包格式(16字节包头)
|
||||
|
||||
```
|
||||
┌──────────┬──────────┬──────────┬──────────┬──────────┐
|
||||
│ 0–3 │ 4–5 │ 6–7 │ 8–11 │ 12–15 │
|
||||
│ 包总长度 │ 头部长度 │ 协议版本 │ 操作码 │ 序列号 │
|
||||
│ uint32 │ uint16 │ uint16 │ uint32 │ uint32 │
|
||||
│ (BE) │ 固定=16 │ ver │ op │ 固定=1 │
|
||||
└──────────┴──────────┴──────────┴──────────┴──────────┘
|
||||
│ 16+ bytes: body (JSON 字符串 或 压缩数据) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 操作码 (op)
|
||||
| op | 名称 | 方向 | 说明 |
|
||||
|----|------|------|------|
|
||||
| 2 | HEARTBEAT | 客→服 | 心跳包,body 为空 |
|
||||
| 3 | HEARTBEAT_REPLY | 服→客 | 心跳回应,body 是 uint32 在线人数 |
|
||||
| 5 | MESSAGE | 服→客 | 弹幕/通知消息 |
|
||||
| 7 | USER_AUTHENTICATION | 客→服 | 进房认证包 |
|
||||
| 8 | CONNECT_SUCCESS | 服→客 | 认证成功 |
|
||||
|
||||
### 协议版本 (ver)
|
||||
| ver | 说明 | 解压方式 |
|
||||
|-----|------|---------|
|
||||
| 0 | 原始 JSON | 直接 `JSON.parse` |
|
||||
| 1 | uint32 | `DataView.getUint32(0)` |
|
||||
| 2 | zlib 压缩包束 | `pako.inflate()` → 递归解包 |
|
||||
| 3 | brotli 压缩包束 | `BrotliDecode()` → 递归解包 |
|
||||
|
||||
> ver=2/3 解压后包含**多个子包**拼接,需按每个包头的 totalLen 逐个切割。
|
||||
|
||||
---
|
||||
|
||||
## 三、认证包 (op=7) 的 body 字段
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": 0,
|
||||
"roomid": 12345678,
|
||||
"protover": 3,
|
||||
"platform": "web",
|
||||
"type": 2,
|
||||
"key": "<token from getDanmuInfo>",
|
||||
"buvid": "<buvid3 cookie>"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| uid | 是 | 未登录填 0 |
|
||||
| roomid | 是 | 真实房间号(非短号) |
|
||||
| protover | 是 | 推荐 3(brotli),或 2(zlib) |
|
||||
| platform | 是 | 固定 `"web"` |
|
||||
| type | 是 | 固定 `2` |
|
||||
| key | 是 | getDanmuInfo 返回的 token |
|
||||
| buvid | 推荐 | buvid3 cookie 值,不填可能被限流 |
|
||||
|
||||
---
|
||||
|
||||
## 四、心跳逻辑
|
||||
|
||||
```
|
||||
onopen
|
||||
└─ 发送 AUTH (op=7)
|
||||
|
||||
收到 op=8 (CONNECT_SUCCESS)
|
||||
└─ 立即发送第一次心跳 (op=2, body='')
|
||||
|
||||
收到 op=3 (HEARTBEAT_REPLY)
|
||||
├─ 清除上次计时器
|
||||
└─ 30秒后发送下次心跳 (op=2)
|
||||
```
|
||||
|
||||
**重点**:心跳是在收到 op=3 后才重排计时,而不是固定 setInterval。
|
||||
这样即使因网络延迟导致心跳回复慢,也不会提前发第二次心跳。
|
||||
|
||||
---
|
||||
|
||||
## 五、getDanmuInfo API
|
||||
|
||||
```
|
||||
GET https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo
|
||||
?id={roomId}
|
||||
&w_rid=xxx ← WBI 签名参数
|
||||
&wts=xxx
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"token": "L_DSj3cX...",
|
||||
"host_list": [
|
||||
{ "host": "tx-gz-live-comet.chat.bilibili.com", "wss_port": 443, "ws_port": 2244 },
|
||||
{ "host": "broadcastlv.chat.bilibili.com", "wss_port": 443, "ws_port": 2244 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
也可使用旧版 `getConf`(无需 WBI 签名,但已被标记为 deprecated):
|
||||
```
|
||||
GET https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={roomId}&platform=h5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、与我们现有实现的差异
|
||||
|
||||
| 对比项 | 我们现在 | 参考项目 | 建议 |
|
||||
|--------|---------|---------|------|
|
||||
| API | `getConf`(老接口) | `getDanmuInfo`(新接口) | 可选,两个都能用 |
|
||||
| WBI 签名 | 有(之前因 -352 移除) | 有 | 如果遇到 -352 可用 getConf |
|
||||
| `protover` | 2(zlib) | 3(brotli)或 2 | 先用 2,brotli 需 polyfill |
|
||||
| auth 字段 | 缺少 `platform`、`buvid` | 有 `platform: "web"`、`buvid` | **需补充** |
|
||||
| 心跳方式 | `setInterval` 固定 30s | 收到 op=3 后计时 30s | 建议改为 op=3 驱动 |
|
||||
| buvid3 | 未传入 auth | 作为 `buvid` 字段传入 auth | **需补充** |
|
||||
|
||||
---
|
||||
|
||||
## 七、我们代码需要的改进
|
||||
|
||||
### 1. 补充 auth 字段
|
||||
```ts
|
||||
const authBody = JSON.stringify({
|
||||
uid: 0,
|
||||
roomid: roomId,
|
||||
protover: 2,
|
||||
platform: 'web', // ← 缺少
|
||||
type: 2,
|
||||
key: token,
|
||||
buvid: buvid3, // ← 缺少(从 AsyncStorage 读取)
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 心跳改为 op=3 驱动
|
||||
```ts
|
||||
// 收到 op=8 → 立即发第一次心跳
|
||||
if (pkt.op === 8) {
|
||||
sendHeartbeat();
|
||||
}
|
||||
// 收到 op=3 → 30秒后再发
|
||||
if (pkt.op === 3) {
|
||||
clearTimeout(heartbeatTimer);
|
||||
heartbeatTimer = setTimeout(sendHeartbeat, 30000);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. ver=3 (brotli) 解压支持
|
||||
如果 protover 设为 3,需要引入 brotli 解码库(React Native 需要 polyfill)。
|
||||
建议暂时保持 protover=2(zlib),pako 已支持。
|
||||
|
||||
---
|
||||
|
||||
## 八、弹幕消息 cmd 类型
|
||||
|
||||
op=5 的 body 是 JSON,`cmd` 字段标识消息类型:
|
||||
|
||||
| cmd | 说明 |
|
||||
|-----|------|
|
||||
| `DANMU_MSG` | 弹幕消息,`info[1]` 是文字 |
|
||||
| `SEND_GIFT` | 送礼物 |
|
||||
| `GUARD_BUY` | 上舰 |
|
||||
| `SUPER_CHAT_MESSAGE` | SC 醒目留言 |
|
||||
| `INTERACT_WORD` | 进入直播间/关注 |
|
||||
| `WATCHED_CHANGE` | 观看人数变化 |
|
||||
| `ONLINE_RANK_COUNT` | 在线排名人数 |
|
||||
| `ROOM_CHANGE` | 直播间信息变更 |
|
||||
| `LIVE` | 开播 |
|
||||
| `PREPARING` | 下播 |
|
||||
|
||||
---
|
||||
|
||||
## 九、DANMU_MSG info 数组结构
|
||||
|
||||
```
|
||||
info[0][2] 弹幕颜色(十进制 RGB)
|
||||
info[1] 弹幕文字内容
|
||||
info[2][0] 用户 uid
|
||||
info[2][1] 用户名
|
||||
info[2][2] 是否房管(1=是)
|
||||
info[3][0] 粉丝勋章等级
|
||||
info[3][1] 粉丝勋章名
|
||||
info[7] 舰长等级(0无,1总督,2提督,3舰长)
|
||||
```
|
||||
@@ -1,196 +0,0 @@
|
||||
# Bug Report
|
||||
|
||||
> 最后更新:2026-03-10
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### BUG-01 · `video.stat` 空指针崩溃
|
||||
**文件**: `app/video/[bvid].tsx` 第 72–75、94 行
|
||||
**类型**: Logic Bug
|
||||
|
||||
`VideoItem.stat` 在类型定义中为可选字段(`stat?: {...}`),但代码直接访问 `video.stat.view` 等属性,无任何空值保护。当 B站 API 返回的视频数据不含 `stat` 字段时,页面会崩溃。
|
||||
|
||||
```tsx
|
||||
// 当前(危险)
|
||||
<StatBadge icon="play" count={video.stat.view} />
|
||||
评论 {video.stat.reply > 0 ? ...}
|
||||
|
||||
// 修复
|
||||
<StatBadge icon="play" count={video.stat?.view ?? 0} />
|
||||
评论 {(video.stat?.reply ?? 0) > 0 ? ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BUG-02 · 切换视频时评论不重置
|
||||
**文件**: `hooks/useComments.ts`、`app/video/[bvid].tsx` 第 31–33 行
|
||||
**类型**: Logic Bug
|
||||
|
||||
`useComments` hook 内部没有重置机制。当用户从视频 A(aid=123,已加载 3 页)跳转到视频 B(aid=456)时:
|
||||
- `comments` 数组仍含视频 A 的内容
|
||||
- `page` 仍为 4,视频 B 的评论从第 4 页开始请求
|
||||
- 结果:视频 A 和视频 B 的评论混在一起显示
|
||||
|
||||
`useComments` 缺少一个 `reset()` 方法,`[bvid].tsx` 也没有在 `bvid` 变化时调用清理。
|
||||
|
||||
```ts
|
||||
// hooks/useComments.ts 需要增加
|
||||
const reset = useCallback(() => {
|
||||
setComments([]);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
}, []);
|
||||
|
||||
// app/video/[bvid].tsx 需要在 bvid 变化时调用
|
||||
useEffect(() => {
|
||||
clearVideo();
|
||||
resetComments(); // 缺失
|
||||
}, [bvid]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BUG-03 · `useComments` 跨视频 `page` 状态污染
|
||||
**文件**: `hooks/useComments.ts` 第 24 行
|
||||
**类型**: Race Condition
|
||||
|
||||
`load` 回调将 `page`、`loading`、`hasMore` 列入 `useCallback` 依赖数组。当 `aid` 变化(切换视频)时,`page` 仍是上一个视频的值,新 `load` 调用会从错误的页码请求评论。与 BUG-02 同根,但原因不同:即使父组件调用 `reset`,旧 `load` 闭包内的 `page` 可能仍是旧值。
|
||||
|
||||
```ts
|
||||
// 当前
|
||||
}, [aid, page, loading, hasMore]); // page 污染导致跨视频分页错误
|
||||
|
||||
// 修复:用 ref 追踪 page,不纳入依赖
|
||||
const pageRef = useRef(1);
|
||||
const load = useCallback(async () => {
|
||||
...
|
||||
const data = await getComments(aid, pageRef.current);
|
||||
pageRef.current += 1;
|
||||
}, [aid]); // 只依赖 aid
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MAJOR
|
||||
|
||||
### BUG-04 · 视频加载失败无任何用户反馈
|
||||
**文件**: `app/video/[bvid].tsx` 第 22 行
|
||||
**类型**: UX
|
||||
|
||||
`useVideoDetail` 返回 `error` 字段,但 `[bvid].tsx` 完全忽略它。网络错误、API 403、视频下架等场景下,用户看到的是永久的 loading 动画或空白页,无法知晓发生了什么。
|
||||
|
||||
```tsx
|
||||
// 当前(error 被解构但从未使用)
|
||||
const { video, playData, loading: videoLoading, qualities, currentQn, changeQuality } = useVideoDetail(bvid);
|
||||
|
||||
// 修复:增加 error 处理
|
||||
const { video, playData, loading: videoLoading, error, qualities, currentQn, changeQuality } = useVideoDetail(bvid);
|
||||
// 在 ScrollView 内渲染
|
||||
{error && <Text style={styles.errorTxt}>加载失败:{error}</Text>}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BUG-05 · `Promise.all` 导致热力图和缩略图互相阻断
|
||||
**文件**: `components/NativeVideoPlayer.tsx`(heatmap useEffect)
|
||||
**类型**: Error Handling
|
||||
|
||||
热力图(`getHeatmap`)和视频截图(`getVideoShot`)使用 `Promise.all` 并行请求。任意一个失败,另一个的结果也会被丢弃。对于没有热力图的视频,缩略图也无法加载。
|
||||
|
||||
```ts
|
||||
// 当前
|
||||
Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(...)
|
||||
|
||||
// 修复:独立处理各自失败
|
||||
Promise.allSettled([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(([heatRes, shotRes]) => {
|
||||
if (heatRes.status === 'fulfilled' && heatRes.value?.pb_data) { ... }
|
||||
if (shotRes.status === 'fulfilled' && shotRes.value?.image?.length) { ... }
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BUG-06 · LoginModal `pollQRCode` 无错误处理
|
||||
**文件**: `components/LoginModal.tsx`(setInterval 回调)
|
||||
**类型**: Error Handling
|
||||
|
||||
`setInterval` 内的 `await pollQRCode(qrKey)` 若抛出异常(网络断开、超时等),错误被静默丢弃,定时器继续运行。用户看到的是二维码一直停在"等待扫码"状态,没有任何提示。
|
||||
|
||||
```ts
|
||||
// 修复:增加 try-catch
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await pollQRCode(qrKey);
|
||||
...
|
||||
} catch {
|
||||
// 网络异常时更新状态提示用户
|
||||
setStatus('error');
|
||||
}
|
||||
}, 2000);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BUG-07 · `utils/buildMpd.ts` 死代码
|
||||
**文件**: `utils/buildMpd.ts`
|
||||
**类型**: Code Quality
|
||||
|
||||
该文件定义了 `buildMpd()` 函数,但全项目无任何 import。实际 MPD 构建逻辑在 `utils/dash.ts` 中实现。`buildMpd.ts` 中的 `SegmentBase` 使用硬编码的 `range="0-999"` 而非真实的 byte range,直接使用会导致 ExoPlayer 播放失败。应删除该文件。
|
||||
|
||||
---
|
||||
|
||||
## MINOR
|
||||
|
||||
### BUG-08 · `expo-file-system/legacy` 废弃导入
|
||||
**文件**: `utils/dash.ts` 第 1 行
|
||||
**类型**: Deprecation
|
||||
|
||||
```ts
|
||||
import * as FileSystem from 'expo-file-system/legacy'; // 废弃路径
|
||||
```
|
||||
|
||||
新版 expo-file-system 将 legacy API 移至子路径是临时过渡方案,后续版本可能移除。需跟进 expo-file-system 更新,迁移至正式 API。
|
||||
|
||||
---
|
||||
|
||||
### BUG-09 · 进度条 `measureInWindow` 异步竞态
|
||||
**文件**: `components/NativeVideoPlayer.tsx`(`measureTrack`)
|
||||
**类型**: Race Condition
|
||||
|
||||
控制层从隐藏变为显示时,`trackWrapper` 重新挂载,`onLayout` 触发 `measureInWindow`(异步)。若用户在 `measureInWindow` 回调返回前立即拖动进度条,`barOffsetX.current` 为旧值(0 或上次值),导致 `touchX` 计算偏差,缩略图位置和 seek 位置偏移。
|
||||
|
||||
---
|
||||
|
||||
### BUG-10 · "加载更多评论"按钮在首次加载前不可见
|
||||
**文件**: `app/video/[bvid].tsx` 第 108–112 行
|
||||
**类型**: UX
|
||||
|
||||
按钮渲染条件为 `!cmtLoading && comments.length > 0`。首次进入视频详情时,评论需要用户手动点击"加载更多"触发,但在 `loadComments` 被 `useEffect` 自动调用(第 32 行)完成前,按钮不存在。若自动加载失败,用户无法重试。
|
||||
|
||||
---
|
||||
|
||||
### BUG-11 · `useVideoDetail` 登录态变化时不重置 loading
|
||||
**文件**: `hooks/useVideoDetail.ts` 第 53–57 行
|
||||
**类型**: UX
|
||||
|
||||
登录/登出时触发 `fetchPlayData` 重新拉取清晰度,但未设置 `loading = true`。用户看不到加载状态变化,若请求耗时较长,可能出现短暂的旧清晰度视频在播放的情况。
|
||||
|
||||
---
|
||||
|
||||
## 汇总
|
||||
|
||||
| ID | 文件 | 严重度 | 类型 | 一句话描述 |
|
||||
|----|------|--------|------|-----------|
|
||||
| BUG-01 | `app/video/[bvid].tsx` | CRITICAL | Logic Bug | `video.stat` 无空值保护,可能崩溃 |
|
||||
| BUG-02 | `hooks/useComments.ts` | CRITICAL | Logic Bug | 切换视频时评论状态不清空 |
|
||||
| BUG-03 | `hooks/useComments.ts` | CRITICAL | Race Condition | `page` 状态跨视频污染 |
|
||||
| BUG-04 | `app/video/[bvid].tsx` | MAJOR | UX | 视频加载失败无任何用户反馈 |
|
||||
| BUG-05 | `components/NativeVideoPlayer.tsx` | MAJOR | Error Handling | `Promise.all` 导致热力图/缩略图互相阻断 |
|
||||
| BUG-06 | `components/LoginModal.tsx` | MAJOR | Error Handling | 二维码轮询网络异常被静默吞掉 |
|
||||
| BUG-07 | `utils/buildMpd.ts` | MAJOR | Code Quality | 死代码,硬编码错误 byte range |
|
||||
| BUG-08 | `utils/dash.ts` | MINOR | Deprecation | `expo-file-system/legacy` 废弃导入 |
|
||||
| BUG-09 | `components/NativeVideoPlayer.tsx` | MINOR | Race Condition | 进度条测量异步竞态导致拖动偏差 |
|
||||
| BUG-10 | `app/video/[bvid].tsx` | MINOR | UX | 首次加载失败无重试入口 |
|
||||
| BUG-11 | `hooks/useVideoDetail.ts` | MINOR | UX | 登录态变化时不显示重新加载状态 |
|
||||
@@ -1,276 +0,0 @@
|
||||
# DASH 1080P Playback Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 Android 上解锁 1080P+ 画质,通过切换至 DASH 格式并生成合法 MPD data URI 传给 ExoPlayer。
|
||||
|
||||
**Architecture:** `getPlayUrl` 按平台分叉——Android 使用 `fnval=16` 获取 DASH JSON(含分离的 video/audio 流及 segmentBase 范围);iOS/Web 保持 `fnval=0,platform=html5`(720P MP4)。DASH 数据在 `utils/dash.ts` 中组装成标准 MPD XML,经 `btoa` 编码为 `data:` URI,传给 `react-native-video` 的 ExoPlayer 播放。
|
||||
|
||||
**Tech Stack:** React Native 0.83, Expo SDK 55, react-native-video 6.x (ExoPlayer on Android), TypeScript
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|---|---|---|
|
||||
| `services/types.ts` | Modify | 扩展 dash video/audio 条目类型,加 segmentBase/width/height/mimeType/frameRate |
|
||||
| `services/bilibili.ts` | Modify | `getPlayUrl` 按 Platform.OS 分叉 fnval |
|
||||
| `utils/dash.ts` | **Create** | `buildDashDataUri(playData, qn): string` |
|
||||
| `components/NativeVideoPlayer.tsx` | Modify | 有 dash 时用 data URI + `type="mpd"`,否则走原 durl 路径 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 扩展 PlayUrlResponse 类型
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/types.ts`
|
||||
|
||||
- [ ] **Step 1: 更新 dash video/audio 条目类型,加入 B 站实际返回字段**
|
||||
|
||||
```typescript
|
||||
// services/types.ts — 替换现有 PlayUrlResponse
|
||||
|
||||
export interface DashSegmentBase {
|
||||
Initialization: string; // e.g. "0-938"
|
||||
indexRange: string; // e.g. "939-2694"
|
||||
}
|
||||
|
||||
export interface DashVideoItem {
|
||||
id: number;
|
||||
baseUrl: string;
|
||||
bandwidth: number;
|
||||
mimeType: string; // "video/mp4"
|
||||
codecs: string; // "avc1.640028"
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: string; // "25" or "25000/1000"
|
||||
segmentBase: DashSegmentBase;
|
||||
}
|
||||
|
||||
export interface DashAudioItem {
|
||||
id: number;
|
||||
baseUrl: string;
|
||||
bandwidth: number;
|
||||
mimeType: string; // "audio/mp4"
|
||||
codecs: string; // "mp4a.40.2"
|
||||
segmentBase: DashSegmentBase;
|
||||
}
|
||||
|
||||
export interface PlayUrlResponse {
|
||||
durl?: Array<{
|
||||
url: string;
|
||||
length: number;
|
||||
size: number;
|
||||
}>;
|
||||
dash?: {
|
||||
duration: number;
|
||||
video: DashVideoItem[];
|
||||
audio: DashAudioItem[];
|
||||
};
|
||||
quality: number;
|
||||
accept_quality: number[];
|
||||
accept_description: string[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add services/types.ts
|
||||
git commit -m "feat: extend PlayUrlResponse types for DASH segmentBase fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: getPlayUrl 按平台分叉
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/bilibili.ts` (line 97–102)
|
||||
|
||||
- [ ] **Step 1: 修改 `getPlayUrl` 函数**
|
||||
|
||||
将现有函数替换为:
|
||||
|
||||
```typescript
|
||||
export async function getPlayUrl(bvid: string, cid: number, qn = 64): Promise<PlayUrlResponse> {
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
const params = isAndroid
|
||||
? { bvid, cid, qn, fnval: 16, fourk: 1 }
|
||||
: { bvid, cid, qn, fnval: 0, platform: 'html5', fourk: 1 };
|
||||
const res = await api.get('/x/player/playurl', { params });
|
||||
return res.data.data as PlayUrlResponse;
|
||||
}
|
||||
```
|
||||
|
||||
注:`Platform` 已在文件顶部从 `react-native` 导入(第 3 行),无需新增 import。
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add services/bilibili.ts
|
||||
git commit -m "feat: use DASH (fnval=16) on Android for getPlayUrl"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 新建 utils/dash.ts
|
||||
|
||||
**Files:**
|
||||
- Create: `utils/dash.ts`
|
||||
|
||||
- [ ] **Step 1: 创建文件,实现 `buildDashDataUri`**
|
||||
|
||||
```typescript
|
||||
// utils/dash.ts
|
||||
import type { PlayUrlResponse } from '../services/types';
|
||||
|
||||
/**
|
||||
* 从 Bilibili DASH 响应生成 MPD data URI。
|
||||
* 选取 id === qn 的视频流(找不到则取第一条),带宽最高的音频流。
|
||||
* 返回 "data:application/dash+xml;base64,..." 供 react-native-video 使用。
|
||||
*/
|
||||
export function buildDashDataUri(playData: PlayUrlResponse, qn: number): string {
|
||||
const dash = playData.dash!;
|
||||
|
||||
const video =
|
||||
dash.video.find(v => v.id === qn) ?? dash.video[0];
|
||||
const audio = dash.audio.reduce((best, a) =>
|
||||
a.bandwidth > best.bandwidth ? a : best
|
||||
);
|
||||
|
||||
const dur = dash.duration;
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011"
|
||||
profiles="urn:mpeg:dash:profile:isoff-on-demand:2011"
|
||||
type="static"
|
||||
mediaPresentationDuration="PT${dur}S">
|
||||
<Period duration="PT${dur}S">
|
||||
<AdaptationSet id="1" mimeType="${video.mimeType}" codecs="${video.codecs}" startWithSAP="1" subsegmentAlignment="true">
|
||||
<Representation id="v1" bandwidth="${video.bandwidth}" width="${video.width}" height="${video.height}" frameRate="${video.frameRate}">
|
||||
<BaseURL>${escapeXml(video.baseUrl)}</BaseURL>
|
||||
<SegmentBase indexRange="${video.segmentBase.indexRange}">
|
||||
<Initialization range="${video.segmentBase.Initialization}"/>
|
||||
</SegmentBase>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
<AdaptationSet id="2" mimeType="${audio.mimeType}" codecs="${audio.codecs}" startWithSAP="1" subsegmentAlignment="true">
|
||||
<Representation id="a1" bandwidth="${audio.bandwidth}">
|
||||
<BaseURL>${escapeXml(audio.baseUrl)}</BaseURL>
|
||||
<SegmentBase indexRange="${audio.segmentBase.indexRange}">
|
||||
<Initialization range="${audio.segmentBase.Initialization}"/>
|
||||
</SegmentBase>
|
||||
</Representation>
|
||||
</AdaptationSet>
|
||||
</Period>
|
||||
</MPD>`;
|
||||
|
||||
// btoa is available in React Native's Hermes JS engine
|
||||
return `data:application/dash+xml;base64,${btoa(xml)}`;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add utils/dash.ts
|
||||
git commit -m "feat: add buildDashDataUri utility for DASH MPD generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 更新 NativeVideoPlayer 使用 DASH data URI
|
||||
|
||||
**Files:**
|
||||
- Modify: `components/NativeVideoPlayer.tsx`
|
||||
|
||||
- [ ] **Step 1: 导入 `buildDashDataUri`,更新 url 选取逻辑**
|
||||
|
||||
在文件顶部 import 区域添加:
|
||||
```typescript
|
||||
import { buildDashDataUri } from '../utils/dash';
|
||||
```
|
||||
|
||||
将第 34 行的 url 计算替换为(在组件函数体内):
|
||||
|
||||
```typescript
|
||||
// 优先使用 DASH data URI(Android),降级使用 durl MP4(iOS)
|
||||
const url = playData?.dash
|
||||
? buildDashDataUri(playData, currentQn)
|
||||
: playData?.durl?.[0]?.url;
|
||||
```
|
||||
|
||||
将 `<Video>` 组件的 `source` prop 更新,DASH 时传入 `type` hint:
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={
|
||||
playData?.dash
|
||||
? { uri: url!, type: 'mpd', headers: BILIBILI_HEADERS }
|
||||
: { uri: url!, headers: BILIBILI_HEADERS }
|
||||
}
|
||||
style={styles.video}
|
||||
resizeMode="contain"
|
||||
controls
|
||||
paused={false}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 手动测试清单**
|
||||
|
||||
在 Android 设备 / 模拟器上:
|
||||
1. `expo run:android`(需要 dev build,react-native-video 是 native 模块)
|
||||
2. 打开任意视频详情页
|
||||
3. 确认播放器加载且**有声音有画面**
|
||||
4. 点击右上角清晰度按钮,检查可选项是否包含 1080P(80)或更高
|
||||
5. 切换清晰度,确认切换后正常播放
|
||||
6. (可选)登录后重新打开视频,确认 `accept_quality` 中出现更高画质选项
|
||||
|
||||
在 iOS(如有设备):
|
||||
1. 确认退化到 720P MP4 durl 路径,视频正常播放无报错
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add components/NativeVideoPlayer.tsx
|
||||
git commit -m "feat: play DASH streams on Android for 1080P+ quality"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成标志
|
||||
|
||||
- [ ] Android 上视频清晰度列表出现 1080P(qn=80)及以上选项
|
||||
- [ ] 切换清晰度后视频带音频正常播放
|
||||
- [ ] iOS 仍可正常播放(720P durl 路径)
|
||||
- [ ] `npx tsc --noEmit` 无类型错误
|
||||
@@ -1,627 +0,0 @@
|
||||
# Player UI Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 重写 NativeVideoPlayer,将热度进度条 + 缩略图预览整合进统一自定义控制层(tap-to-show/3s 自动隐藏),删除播放器下方独立的 HeatProgressBar。
|
||||
|
||||
**Architecture:** 关闭 react-native-video 原生控制栏(`controls={false}`),用绝对定位 LinearGradient overlay 实现顶部栏 + 中心播放按钮 + 底部进度条控制栏。HeatProgressBar 的热度图解码和缩略图逻辑搬入 NativeVideoPlayer,`bvid`/`cid` 新增为 props 向下透传。VideoPlayer 和 [bvid].tsx 相应删减 state 和旧 props。
|
||||
|
||||
**Tech Stack:** React Native 0.83, react-native-video 6.x, expo-linear-gradient, PanResponder, Ionicons
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 重写 NativeVideoPlayer
|
||||
|
||||
**Files:**
|
||||
- Modify: `components/NativeVideoPlayer.tsx` (完全重写)
|
||||
|
||||
- [ ] **Step 1: 用以下完整代码替换 NativeVideoPlayer.tsx**
|
||||
|
||||
```tsx
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View, StyleSheet, Dimensions, TouchableOpacity, TouchableWithoutFeedback,
|
||||
Text, Modal, Image, PanResponder,
|
||||
} from 'react-native';
|
||||
import Video, { VideoRef } from 'react-native-video';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { PlayUrlResponse, VideoShotData } from '../services/types';
|
||||
import { buildDashMpdUri } from '../utils/dash';
|
||||
import { getHeatmap, getVideoShot } from '../services/bilibili';
|
||||
|
||||
const { width: SCREEN_W } = Dimensions.get('window');
|
||||
const VIDEO_H = SCREEN_W * 0.5625;
|
||||
const BAR_H = 3;
|
||||
const BALL = 12;
|
||||
const BALL_ACTIVE = 16;
|
||||
const SEGMENTS = 100;
|
||||
const HIDE_DELAY = 3000;
|
||||
|
||||
const HEADERS = {
|
||||
Referer: 'https://www.bilibili.com',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
};
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
function heatColor(v: number): string {
|
||||
if (v < 0.5) {
|
||||
const t = v * 2;
|
||||
return `rgb(${Math.round(t * 255)},174,236)`;
|
||||
}
|
||||
const t = (v - 0.5) * 2;
|
||||
return `rgb(251,${Math.round((1 - t) * 114)},${Math.round((1 - t) * 153)})`;
|
||||
}
|
||||
|
||||
function decodeFloats(base64: string): number[] {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
const view = new DataView(bytes.buffer);
|
||||
const floats: number[] = [];
|
||||
let i = 0;
|
||||
while (i < bytes.length) {
|
||||
const tag = bytes[i++];
|
||||
const wt = tag & 0x7;
|
||||
if (wt === 5) { floats.push(view.getFloat32(i, true)); i += 4; }
|
||||
else if (wt === 0) { while (i < bytes.length && (bytes[i++] & 0x80)); }
|
||||
else if (wt === 1) { i += 8; }
|
||||
else if (wt === 2) {
|
||||
let len = 0, shift = 0;
|
||||
do { const b = bytes[i++]; len |= (b & 0x7f) << shift; shift += 7; if (!(b & 0x80)) break; } while (true);
|
||||
i += len;
|
||||
} else break;
|
||||
}
|
||||
return floats;
|
||||
}
|
||||
|
||||
function downsample(data: number[], n: number): number[] {
|
||||
if (!data.length) return Array(n).fill(0);
|
||||
const out = Array.from({ length: n }, (_, i) => data[Math.floor((i / n) * data.length)]);
|
||||
const max = Math.max(...out);
|
||||
return max ? out.map(v => v / max) : out;
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
playData: PlayUrlResponse | null;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
currentQn: number;
|
||||
onQualityChange: (qn: number) => void;
|
||||
onFullscreen: () => void;
|
||||
onMiniPlayer?: () => void;
|
||||
style?: object;
|
||||
bvid?: string;
|
||||
cid?: number;
|
||||
}
|
||||
|
||||
export function NativeVideoPlayer({
|
||||
playData, qualities, currentQn, onQualityChange, onFullscreen, onMiniPlayer, style,
|
||||
bvid, cid,
|
||||
}: Props) {
|
||||
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>();
|
||||
const isDash = !!playData?.dash;
|
||||
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const durationRef = useRef(0);
|
||||
|
||||
const [showQuality, setShowQuality] = useState(false);
|
||||
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const isSeekingRef = useRef(false);
|
||||
const [touchX, setTouchX] = useState<number | null>(null);
|
||||
const barOffsetX = useRef(0);
|
||||
const barWidthRef = useRef(SCREEN_W);
|
||||
const trackRef = useRef<View>(null);
|
||||
|
||||
const [heatSegments, setHeatSegments] = useState<number[]>([]);
|
||||
const [shots, setShots] = useState<VideoShotData | null>(null);
|
||||
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
const currentDesc = qualities.find(q => q.qn === currentQn)?.desc ?? String(currentQn || 'HD');
|
||||
|
||||
// URL resolution
|
||||
useEffect(() => {
|
||||
if (!playData) { setResolvedUrl(undefined); return; }
|
||||
if (isDash) {
|
||||
buildDashMpdUri(playData, currentQn)
|
||||
.then(setResolvedUrl)
|
||||
.catch(() => setResolvedUrl(playData.dash!.video[0]?.baseUrl));
|
||||
} else {
|
||||
setResolvedUrl(playData.durl?.[0]?.url);
|
||||
}
|
||||
}, [playData, currentQn]);
|
||||
|
||||
// Heatmap + shots
|
||||
useEffect(() => {
|
||||
if (!bvid || !cid) return;
|
||||
let cancelled = false;
|
||||
Promise.all([getHeatmap(bvid), getVideoShot(bvid, cid)]).then(([heatmap, shotData]) => {
|
||||
if (cancelled) return;
|
||||
if (heatmap?.pb_data) {
|
||||
try { setHeatSegments(downsample(decodeFloats(heatmap.pb_data), SEGMENTS)); }
|
||||
catch { setHeatSegments([]); }
|
||||
}
|
||||
if (shotData?.image?.length) setShots(shotData);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [bvid, cid]);
|
||||
|
||||
useEffect(() => { durationRef.current = duration; }, [duration]);
|
||||
|
||||
const resetHideTimer = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (!isSeekingRef.current) {
|
||||
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showAndReset = useCallback(() => {
|
||||
setShowControls(true);
|
||||
resetHideTimer();
|
||||
}, [resetHideTimer]);
|
||||
|
||||
const handleTap = useCallback(() => {
|
||||
setShowControls(prev => {
|
||||
if (!prev) { resetHideTimer(); return true; }
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
return false;
|
||||
});
|
||||
}, [resetHideTimer]);
|
||||
|
||||
// Start hide timer on mount
|
||||
useEffect(() => { resetHideTimer(); return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; }, []);
|
||||
|
||||
const measureTrack = useCallback(() => {
|
||||
trackRef.current?.measureInWindow((x, _y, w) => {
|
||||
barOffsetX.current = x;
|
||||
barWidthRef.current = w;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: (_, gs) => {
|
||||
isSeekingRef.current = true;
|
||||
setIsSeeking(true);
|
||||
setShowControls(true);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
setTouchX(clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current));
|
||||
},
|
||||
onPanResponderMove: (_, gs) => {
|
||||
setTouchX(clamp(gs.moveX - barOffsetX.current, 0, barWidthRef.current));
|
||||
},
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
const ratio = clamp((gs.moveX - barOffsetX.current) / barWidthRef.current, 0, 1);
|
||||
const t = ratio * durationRef.current;
|
||||
videoRef.current?.seek(t);
|
||||
setCurrentTime(t);
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
// use setTimeout to avoid stale resetHideTimer closure
|
||||
setTimeout(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
|
||||
}, 0);
|
||||
},
|
||||
onPanResponderTerminate: () => {
|
||||
setTouchX(null);
|
||||
isSeekingRef.current = false;
|
||||
setIsSeeking(false);
|
||||
},
|
||||
})
|
||||
).current;
|
||||
|
||||
const touchRatio = touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
|
||||
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
|
||||
|
||||
const renderThumbnail = () => {
|
||||
if (touchRatio === null || !shots) return null;
|
||||
const { img_x_size: TW, img_y_size: TH, img_x_len, img_y_len, image } = shots;
|
||||
const totalFrames = img_x_len * img_y_len * image.length;
|
||||
const framesPerSheet = img_x_len * img_y_len;
|
||||
const frameIdx = Math.floor(touchRatio * (totalFrames - 1));
|
||||
const sheetIdx = Math.floor(frameIdx / framesPerSheet);
|
||||
const local = frameIdx % framesPerSheet;
|
||||
const col = local % img_x_len;
|
||||
const row = Math.floor(local / img_x_len);
|
||||
const left = clamp((touchX ?? 0) - TW / 2, 0, barWidthRef.current - TW);
|
||||
return (
|
||||
<View style={[styles.thumbPreview, { left, width: TW }]}>
|
||||
<View style={{ width: TW, height: TH, overflow: 'hidden', borderRadius: 4 }}>
|
||||
<Image
|
||||
source={{ uri: image[sheetIdx] }}
|
||||
style={{ position: 'absolute', width: TW * img_x_len, height: TH * img_y_len, left: -col * TW, top: -row * TH }}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.thumbTime}>{formatTime((touchRatio ?? 0) * duration)}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={handleTap}>
|
||||
<View style={[styles.container, style]}>
|
||||
{resolvedUrl ? (
|
||||
<Video
|
||||
key={resolvedUrl}
|
||||
ref={videoRef}
|
||||
source={isDash
|
||||
? { uri: resolvedUrl, type: 'mpd', headers: HEADERS }
|
||||
: { uri: resolvedUrl, headers: HEADERS }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="contain"
|
||||
controls={false}
|
||||
paused={paused}
|
||||
onProgress={({ currentTime: ct, seekableDuration: dur }) => {
|
||||
setCurrentTime(ct);
|
||||
if (dur > 0) setDuration(dur);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholder} />
|
||||
)}
|
||||
|
||||
{showControls && (
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<LinearGradient colors={['rgba(0,0,0,0.55)', 'transparent']} style={styles.topBar} pointerEvents="box-none">
|
||||
{onMiniPlayer && (
|
||||
<TouchableOpacity onPress={() => { onMiniPlayer(); }} style={styles.topBtn}>
|
||||
<Ionicons name="tablet-portrait-outline" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</LinearGradient>
|
||||
|
||||
{/* Center play/pause */}
|
||||
<TouchableOpacity
|
||||
style={styles.centerBtn}
|
||||
onPress={() => { setPaused(p => !p); showAndReset(); }}
|
||||
>
|
||||
<View style={styles.centerBtnBg}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={28} color="#fff" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<LinearGradient colors={['transparent', 'rgba(0,0,0,0.7)']} style={styles.bottomBar} pointerEvents="box-none">
|
||||
{/* Thumbnail area */}
|
||||
<View style={styles.thumbArea} pointerEvents="none">
|
||||
{renderThumbnail()}
|
||||
</View>
|
||||
|
||||
{/* Progress track */}
|
||||
<View
|
||||
ref={trackRef}
|
||||
style={styles.trackWrapper}
|
||||
onLayout={measureTrack}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<View style={styles.track}>
|
||||
{heatSegments.length > 0
|
||||
? heatSegments.map((v, i) => (
|
||||
<View key={i} style={[styles.seg, { backgroundColor: heatColor(v), width: `${100 / SEGMENTS}%` as any }]} />
|
||||
))
|
||||
: <View style={[styles.seg, { flex: 1, backgroundColor: '#00AEEC' }]} />
|
||||
}
|
||||
<View style={[styles.playedOverlay, { width: `${progressRatio * 100}%` as any }]} />
|
||||
</View>
|
||||
{/* Balls */}
|
||||
{isSeeking && touchX !== null ? (
|
||||
<View style={[styles.ball, styles.ballActive, { left: touchX - BALL_ACTIVE / 2 }]} />
|
||||
) : (
|
||||
<View style={[styles.ball, { left: progressRatio * barWidthRef.current - BALL / 2 }]} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Controls row */}
|
||||
<View style={styles.ctrlRow}>
|
||||
<TouchableOpacity onPress={() => { setPaused(p => !p); showAndReset(); }} style={styles.ctrlBtn}>
|
||||
<Ionicons name={paused ? 'play' : 'pause'} size={16} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.timeText}>{formatTime(currentTime)}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text style={styles.timeText}>{formatTime(duration)}</Text>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => setShowQuality(true)}>
|
||||
<Text style={styles.qualityText}>{currentDesc}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
|
||||
<Ionicons name="expand" size={16} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Quality modal */}
|
||||
<Modal visible={showQuality} transparent animationType="fade">
|
||||
<TouchableOpacity style={styles.modalOverlay} onPress={() => setShowQuality(false)}>
|
||||
<View style={styles.qualityList}>
|
||||
<Text style={styles.qualityTitle}>选择清晰度</Text>
|
||||
{qualities.map(q => (
|
||||
<TouchableOpacity
|
||||
key={q.qn}
|
||||
style={styles.qualityItem}
|
||||
onPress={() => { setShowQuality(false); onQualityChange(q.qn); showAndReset(); }}
|
||||
>
|
||||
<Text style={[styles.qualityItemText, q.qn === currentQn && styles.qualityItemActive]}>
|
||||
{q.desc}
|
||||
</Text>
|
||||
{q.qn === currentQn && <Ionicons name="checkmark" size={16} color="#00AEEC" />}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { width: SCREEN_W, height: VIDEO_H, backgroundColor: '#000' },
|
||||
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000' },
|
||||
// Top bar
|
||||
topBar: { position: 'absolute', top: 0, left: 0, right: 0, height: 56, paddingHorizontal: 12, paddingTop: 10, flexDirection: 'row', justifyContent: 'flex-end' },
|
||||
topBtn: { padding: 6 },
|
||||
// Center
|
||||
centerBtn: { position: 'absolute', top: '50%', left: '50%', transform: [{ translateX: -28 }, { translateY: -28 }] },
|
||||
centerBtnBg: { width: 56, height: 56, borderRadius: 28, backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center' },
|
||||
// Bottom bar
|
||||
bottomBar: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingBottom: 8, paddingTop: 32 },
|
||||
thumbArea: { position: 'relative', height: 80, marginHorizontal: 8 },
|
||||
thumbPreview: { position: 'absolute', bottom: 4, alignItems: 'center' },
|
||||
thumbTime: { color: '#fff', fontSize: 11, fontWeight: '600', marginTop: 2, textShadowColor: 'rgba(0,0,0,0.7)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2 },
|
||||
// Track
|
||||
trackWrapper: { marginHorizontal: 8, height: BAR_H + BALL_ACTIVE, justifyContent: 'center', position: 'relative' },
|
||||
track: { height: BAR_H, flexDirection: 'row', borderRadius: 2, overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.3)' },
|
||||
seg: { height: BAR_H },
|
||||
playedOverlay: { position: 'absolute', top: 0, left: 0, height: BAR_H, backgroundColor: 'rgba(255,255,255,0.3)' },
|
||||
ball: { position: 'absolute', top: (BAR_H + BALL_ACTIVE) / 2 - BALL / 2, width: BALL, height: BALL, borderRadius: BALL / 2, backgroundColor: '#fff', elevation: 3 },
|
||||
ballActive: { width: BALL_ACTIVE, height: BALL_ACTIVE, borderRadius: BALL_ACTIVE / 2, backgroundColor: '#00AEEC', top: 0 },
|
||||
// Controls row
|
||||
ctrlRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, marginTop: 4 },
|
||||
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
|
||||
timeText: { color: '#fff', fontSize: 11, marginHorizontal: 4 },
|
||||
qualityText: { color: '#fff', fontSize: 11, fontWeight: '600' },
|
||||
// Quality modal
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center' },
|
||||
qualityList: { backgroundColor: '#fff', borderRadius: 12, paddingVertical: 8, paddingHorizontal: 16, minWidth: 180 },
|
||||
qualityTitle: { fontSize: 15, fontWeight: '700', color: '#212121', paddingVertical: 10, textAlign: 'center' },
|
||||
qualityItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#eee' },
|
||||
qualityItemText: { fontSize: 14, color: '#333' },
|
||||
qualityItemActive: { color: '#00AEEC', fontWeight: '700' },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | grep NativeVideoPlayer
|
||||
```
|
||||
|
||||
Expected: no output (no errors)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add components/NativeVideoPlayer.tsx
|
||||
git commit -m "feat: rewrite NativeVideoPlayer with unified custom controls overlay"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 更新 VideoPlayer.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `components/VideoPlayer.tsx`
|
||||
|
||||
- [ ] **Step 1: 添加 bvid/cid props,移除 onProgress/seekTo,向下透传**
|
||||
|
||||
将 VideoPlayer.tsx 完整替换为:
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Dimensions, Text, Platform, Modal, TouchableOpacity, StatusBar } from 'react-native';
|
||||
import { NativeVideoPlayer } from './NativeVideoPlayer';
|
||||
import type { PlayUrlResponse } from '../services/types';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const VIDEO_HEIGHT = width * 0.5625;
|
||||
|
||||
interface Props {
|
||||
playData: PlayUrlResponse | null;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
currentQn: number;
|
||||
onQualityChange: (qn: number) => void;
|
||||
onMiniPlayer?: () => void;
|
||||
bvid?: string;
|
||||
cid?: number;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, onMiniPlayer, bvid, cid }: Props) {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
if (!playData) {
|
||||
return (
|
||||
<View style={[styles.container, styles.placeholder]}>
|
||||
<Text style={styles.placeholderText}>视频加载中...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
const url = playData.durl?.[0]?.url ?? '';
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<video
|
||||
src={url}
|
||||
style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any}
|
||||
controls
|
||||
playsInline
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NativeVideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={() => setFullscreen(true)}
|
||||
onMiniPlayer={onMiniPlayer}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
/>
|
||||
|
||||
<Modal visible={fullscreen} animationType="fade" statusBarTranslucent>
|
||||
<StatusBar hidden />
|
||||
<View style={styles.fullscreenContainer}>
|
||||
<NativeVideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={onQualityChange}
|
||||
onFullscreen={() => setFullscreen(false)}
|
||||
bvid={bvid}
|
||||
cid={cid}
|
||||
style={{ width: '100%', height: '100%' } as any}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { width, height: VIDEO_HEIGHT, backgroundColor: '#000' },
|
||||
placeholder: { justifyContent: 'center', alignItems: 'center' },
|
||||
placeholderText: { color: '#fff', fontSize: 14 },
|
||||
fullscreenContainer: { flex: 1, backgroundColor: '#000' },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | grep -E "VideoPlayer|NativeVideoPlayer"
|
||||
```
|
||||
|
||||
Expected: no new errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add components/VideoPlayer.tsx
|
||||
git commit -m "refactor: VideoPlayer adds bvid/cid props, removes onProgress/seekTo"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 清理 [bvid].tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/video/[bvid].tsx`
|
||||
|
||||
- [ ] **Step 1: 删除 HeatProgressBar 相关内容**
|
||||
|
||||
1. 删除 import 行:`import { HeatProgressBar } from '../../components/HeatProgressBar';`
|
||||
2. 删除 3 个 state:`currentTime`, `duration`, `seekCmd`
|
||||
3. 删除 `onProgress` 和 `seekTo` props(从 VideoPlayer 调用处)
|
||||
4. 新增 `bvid={bvid as string}` 和 `cid={video?.cid}` 到 VideoPlayer
|
||||
5. 删除整个 `<HeatProgressBar .../>` JSX 块
|
||||
|
||||
VideoPlayer 调用改为:
|
||||
```tsx
|
||||
<VideoPlayer
|
||||
playData={playData}
|
||||
qualities={qualities}
|
||||
currentQn={currentQn}
|
||||
onQualityChange={changeQuality}
|
||||
onMiniPlayer={handleMiniPlayer}
|
||||
bvid={bvid as string}
|
||||
cid={video?.cid}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | grep bvid
|
||||
```
|
||||
|
||||
Expected: no errors referencing bvid.tsx
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/video/[bvid].tsx
|
||||
git commit -m "refactor: remove HeatProgressBar from video detail page, pass bvid/cid to VideoPlayer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 删除 HeatProgressBar.tsx
|
||||
|
||||
**Files:**
|
||||
- Delete: `components/HeatProgressBar.tsx`
|
||||
|
||||
- [ ] **Step 1: 删除文件**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && rm components/HeatProgressBar.tsx
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 确认无残留引用**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && grep -r "HeatProgressBar" --include="*.tsx" --include="*.ts" .
|
||||
```
|
||||
|
||||
Expected: no output
|
||||
|
||||
- [ ] **Step 3: 全量 TypeScript 检查**
|
||||
|
||||
```bash
|
||||
cd C:/claude-code-studly/reactBilibiliApp && npx tsc --noEmit 2>&1 | grep -v "video.stat"
|
||||
```
|
||||
|
||||
Expected: 只有既有的 `video.stat` 错误,无新增错误
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: unified player controls with heatmap progress bar and thumbnail preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成标志
|
||||
|
||||
- [ ] 打开视频详情页,播放器内有控制层(顶部 pip 按钮 + 中心播放/暂停 + 底部进度条 + 清晰度 + 全屏)
|
||||
- [ ] 3 秒后控制层自动隐藏,点击视频区域重新显示
|
||||
- [ ] 拖拽进度条时显示缩略图预览(需要视频有截图数据)
|
||||
- [ ] 切换清晰度正常工作
|
||||
- [ ] 播放器下方无独立的 HeatProgressBar
|
||||
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
# Big Video Card Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a full-width "big card" as the last item of the homepage video list, which auto-plays the video muted (qn=16, 360P) when scrolled into view, with a 16:9 thumbnail that fades out once the video is ready.
|
||||
|
||||
**Architecture:** Transform `videos: VideoItem[]` into typed rows (`NormalRow | BigRow`) before rendering; FlatList renders rows via `numColumns={1}`; viewport detection uses FlatList's `onViewableItemsChanged`; `BigVideoCard` fetches play URL lazily and renders a `Video` component from `react-native-video` on top of the thumbnail.
|
||||
|
||||
**Tech Stack:** React Native, Expo SDK 55, react-native-video, Animated API, expo-router
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Data utilities
|
||||
|
||||
### Task 1: Create `utils/videoRows.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `utils/videoRows.ts`
|
||||
|
||||
- [ ] **Step 1: Create the file with types and transformation function**
|
||||
|
||||
```ts
|
||||
// utils/videoRows.ts
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export interface NormalRow {
|
||||
type: 'pair';
|
||||
left: VideoItem;
|
||||
right: VideoItem | null;
|
||||
}
|
||||
|
||||
export interface BigRow {
|
||||
type: 'big';
|
||||
item: VideoItem;
|
||||
}
|
||||
|
||||
export type ListRow = NormalRow | BigRow;
|
||||
|
||||
/**
|
||||
* Transform a flat VideoItem array into display rows.
|
||||
* The last item always becomes a full-width BigRow.
|
||||
* All preceding items are grouped into NormalRow pairs.
|
||||
*/
|
||||
export function toListRows(videos: VideoItem[]): ListRow[] {
|
||||
if (videos.length === 0) return [];
|
||||
if (videos.length === 1) return [{ type: 'big', item: videos[0] }];
|
||||
|
||||
const rows: ListRow[] = [];
|
||||
const body = videos.slice(0, videos.length - 1);
|
||||
|
||||
for (let i = 0; i < body.length; i += 2) {
|
||||
rows.push({
|
||||
type: 'pair',
|
||||
left: body[i],
|
||||
right: body[i + 1] ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({ type: 'big', item: videos[videos.length - 1] });
|
||||
return rows;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Manual verification — open a JS REPL or add a temporary console.log**
|
||||
|
||||
In any component or the `_layout.tsx`, temporarily add:
|
||||
```ts
|
||||
import { toListRows } from '../utils/videoRows';
|
||||
const sample = [{ bvid: 'A' }, { bvid: 'B' }, { bvid: 'C' }, { bvid: 'D' }, { bvid: 'E' }] as any;
|
||||
console.log(JSON.stringify(toListRows(sample), null, 2));
|
||||
// Expected: 2 pairs + 1 big row
|
||||
```
|
||||
Remove after verifying output.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add utils/videoRows.ts
|
||||
git commit -m "feat: add videoRows utility for big-card list layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: BigVideoCard component
|
||||
|
||||
### Task 2: Create `components/BigVideoCard.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `components/BigVideoCard.tsx`
|
||||
|
||||
**Overview:** Displays a full-width card with a 16:9 thumbnail. When `isVisible` becomes true, fetches the play URL at qn=16 and renders a muted `Video`. When the video is ready to display, the thumbnail fades out. When `isVisible` becomes false, the video pauses.
|
||||
|
||||
- [ ] **Step 1: Create the component skeleton**
|
||||
|
||||
```tsx
|
||||
// components/BigVideoCard.tsx
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, Image, TouchableOpacity, StyleSheet,
|
||||
Dimensions, Animated,
|
||||
} from 'react-native';
|
||||
import Video from 'react-native-video';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { buildDashMpdUri } from '../utils/dash';
|
||||
import { getPlayUrl, getVideoDetail } from '../services/bilibili';
|
||||
import { proxyImageUrl } from '../utils/imageUrl';
|
||||
import { formatCount, formatDuration } from '../utils/format';
|
||||
import type { VideoItem } from '../services/types';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
const { width: SCREEN_W } = Dimensions.get('window');
|
||||
const THUMB_H = SCREEN_W * 0.5625; // 16:9
|
||||
|
||||
const HEADERS = {
|
||||
Referer: 'https://www.bilibili.com',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
item: VideoItem;
|
||||
isVisible: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function BigVideoCard({ item, isVisible, onPress }: Props) {
|
||||
const [videoUrl, setVideoUrl] = useState<string | undefined>();
|
||||
const [isDash, setIsDash] = useState(false);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const thumbOpacity = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Fetch play URL when visible for the first time
|
||||
useEffect(() => {
|
||||
if (!isVisible || videoUrl) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// cid may be missing from feed items; fetch detail if needed
|
||||
let cid = item.cid;
|
||||
if (!cid) {
|
||||
const detail = await getVideoDetail(item.bvid);
|
||||
cid = detail.cid ?? detail.pages?.[0]?.cid;
|
||||
}
|
||||
if (!cid) return;
|
||||
|
||||
const playData = await getPlayUrl(item.bvid, cid, 16);
|
||||
|
||||
if (playData.dash) {
|
||||
setIsDash(true);
|
||||
try {
|
||||
const mpdUri = await buildDashMpdUri(playData, 16);
|
||||
setVideoUrl(mpdUri);
|
||||
} catch {
|
||||
setVideoUrl(playData.dash.video[0]?.baseUrl);
|
||||
}
|
||||
} else {
|
||||
setVideoUrl(playData.durl?.[0]?.url);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('BigVideoCard: failed to load play URL', e);
|
||||
}
|
||||
})();
|
||||
}, [isVisible]);
|
||||
|
||||
// Pause/resume when visibility changes
|
||||
useEffect(() => {
|
||||
if (!videoUrl) return;
|
||||
setPaused(!isVisible);
|
||||
if (!isVisible) {
|
||||
// Restore thumbnail when leaving viewport
|
||||
Animated.timing(thumbOpacity, { toValue: 1, duration: 150, useNativeDriver: true }).start();
|
||||
}
|
||||
}, [isVisible, videoUrl]);
|
||||
|
||||
const handleVideoReady = () => {
|
||||
setPaused(false);
|
||||
Animated.timing(thumbOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.9}>
|
||||
{/* Media area */}
|
||||
<View style={styles.mediaContainer}>
|
||||
{/* Thumbnail */}
|
||||
<Animated.View style={[StyleSheet.absoluteFill, { opacity: thumbOpacity }]}>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(item.pic) }}
|
||||
style={styles.thumb}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Duration badge on thumbnail */}
|
||||
<View style={styles.durationBadge}>
|
||||
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Video player — only mounted when URL is available */}
|
||||
{videoUrl && (
|
||||
<Video
|
||||
source={
|
||||
isDash
|
||||
? { uri: videoUrl, type: 'mpd', headers: HEADERS }
|
||||
: { uri: videoUrl, headers: HEADERS }
|
||||
}
|
||||
style={StyleSheet.absoluteFill}
|
||||
resizeMode="cover"
|
||||
muted
|
||||
paused={paused}
|
||||
repeat
|
||||
controls={false}
|
||||
onReadyForDisplay={handleVideoReady}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
|
||||
<View style={styles.meta}>
|
||||
<Ionicons name="play" size={11} color="#999" />
|
||||
<Text style={styles.metaText}>{formatCount(item.stat?.view ?? 0)}</Text>
|
||||
</View>
|
||||
<Text style={styles.owner} numberOfLines={1}>{item.owner?.name ?? ''}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
marginHorizontal: 4,
|
||||
marginBottom: 6,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
mediaContainer: {
|
||||
width: SCREEN_W - 8,
|
||||
height: THUMB_H,
|
||||
},
|
||||
thumb: {
|
||||
width: SCREEN_W - 8,
|
||||
height: THUMB_H,
|
||||
},
|
||||
durationBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: 3,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
zIndex: 2,
|
||||
},
|
||||
durationText: { color: '#fff', fontSize: 10 },
|
||||
info: { padding: 8 },
|
||||
title: { fontSize: 14, color: '#212121', lineHeight: 18, marginBottom: 4 },
|
||||
meta: { flexDirection: 'row', alignItems: 'center', gap: 2 },
|
||||
metaText: { fontSize: 11, color: '#999' },
|
||||
owner: { fontSize: 11, color: '#999', marginTop: 2 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the component builds (no TypeScript errors)**
|
||||
|
||||
```bash
|
||||
cd C:\claude-code-studly\reactBilibiliApp
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Expected: no errors related to `BigVideoCard.tsx`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add components/BigVideoCard.tsx
|
||||
git commit -m "feat: add BigVideoCard with muted autoplay at qn=16"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: FlatList integration
|
||||
|
||||
### Task 3: Refactor `app/index.tsx` to use row-based layout
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/index.tsx`
|
||||
|
||||
**Changes:**
|
||||
- Add `useMemo` to existing React import; add `ViewToken` to react-native import
|
||||
- Import `toListRows`, `ListRow`, `BigRow` from `utils/videoRows`
|
||||
- Import `BigVideoCard` from `components/BigVideoCard`
|
||||
- Derive `rows` from `videos` via `useMemo`
|
||||
- Remove `numColumns={2}` and `columnWrapperStyle` prop (keep `styles.row` — it's used in pair renderItem)
|
||||
- Add `VIEWABILITY_CONFIG` constant (module-level)
|
||||
- Add `onViewableItemsChangedRef` (useRef-wrapped)
|
||||
- Add `visibleBigKey` state
|
||||
- Update `renderItem` to branch on row type
|
||||
- Update `keyExtractor` for row keys
|
||||
|
||||
- [ ] **Step 1: Update imports in `app/index.tsx`**
|
||||
|
||||
1a. In the existing `import React, { useEffect, useState, useRef } from "react"` line, add `useMemo`:
|
||||
```ts
|
||||
import React, { useEffect, useState, useRef, useMemo } from "react";
|
||||
```
|
||||
|
||||
1b. In the existing `import { ... } from "react-native"` block, add `ViewToken`:
|
||||
```ts
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Image,
|
||||
RefreshControl,
|
||||
ViewToken,
|
||||
} from "react-native";
|
||||
```
|
||||
|
||||
1c. Add these new imports after the existing import block (use double quotes to match the file style):
|
||||
```ts
|
||||
import { toListRows, type ListRow, type BigRow } from "../utils/videoRows";
|
||||
import { BigVideoCard } from "../components/BigVideoCard";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add module-level viewability config (outside the component)**
|
||||
|
||||
Add after the `NAV_H` constants:
|
||||
```ts
|
||||
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50 };
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add state and derived data inside `HomeScreen`**
|
||||
|
||||
Add inside `HomeScreen`, after the existing `useRef` lines. **Order matters:** declare state first, then memo, then the ref (the ref's closure captures `setVisibleBigKey`):
|
||||
```ts
|
||||
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
|
||||
const rows = useMemo(() => toListRows(videos), [videos]);
|
||||
|
||||
const onViewableItemsChangedRef = useRef(
|
||||
({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
||||
const bigRow = viewableItems.find(
|
||||
(v) => v.item && (v.item as ListRow).type === 'big',
|
||||
);
|
||||
setVisibleBigKey(
|
||||
bigRow ? (bigRow.item as BigRow).item.bvid : null,
|
||||
);
|
||||
},
|
||||
).current;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `renderItem` with a row-aware version and remove stale import**
|
||||
|
||||
First, replace the existing `renderItem` function (keep the file valid at all times):
|
||||
```ts
|
||||
const renderItem = ({ item: row }: { item: ListRow }) => {
|
||||
if (row.type === 'big') {
|
||||
return (
|
||||
<BigVideoCard
|
||||
item={row.item}
|
||||
isVisible={visibleBigKey === row.item.bvid}
|
||||
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Normal pair row
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.leftCol}>
|
||||
<VideoCard
|
||||
item={row.left}
|
||||
onPress={() => router.push(`/video/${row.left.bvid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
{row.right && (
|
||||
<View style={styles.rightCol}>
|
||||
<VideoCard
|
||||
item={row.right}
|
||||
onPress={() => router.push(`/video/${row.right!.bvid}` as any)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Then remove the now-unused import (check the file for any other references to `VideoItem` before deleting — if none found, remove it):
|
||||
```ts
|
||||
// Remove this line:
|
||||
import type { VideoItem } from "../services/types";
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `styles.row` in the StyleSheet**
|
||||
|
||||
`styles.row` was previously used as `columnWrapperStyle` — FlatList automatically applies `flexDirection: 'row'` to column wrappers. Now that it becomes an explicit `<View style={styles.row}>`, `flexDirection: 'row'` must be added explicitly or the pair columns will stack vertically.
|
||||
|
||||
Find the `row` entry in `StyleSheet.create({...})` and update it:
|
||||
```ts
|
||||
row: { flexDirection: 'row', paddingHorizontal: 1, justifyContent: "flex-start" },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update the FlatList props**
|
||||
|
||||
**Important:** Apply Step 4 (new `renderItem`) before or simultaneously with this step. Removing `numColumns` while the old `renderItem` (which relies on `numColumns` to pair items via `index % 2`) is still in place will break layout mid-edit.
|
||||
|
||||
In the `Animated.FlatList`, make these changes:
|
||||
|
||||
1. Change `data={videos}` → `data={rows}`
|
||||
2. Change `keyExtractor` to:
|
||||
```ts
|
||||
keyExtractor={(row: any, index) =>
|
||||
row.type === 'big'
|
||||
? `big-${row.item.bvid}`
|
||||
: `pair-${row.left.bvid}-${row.right?.bvid ?? 'empty'}-${index}`
|
||||
}
|
||||
```
|
||||
3. Remove `numColumns={2}`
|
||||
4. Remove the `columnWrapperStyle={styles.row}` **prop** from FlatList. Do NOT delete the `styles.row` StyleSheet entry — it is still referenced in the pair renderItem's `<View style={styles.row}>` (and now has `flexDirection: 'row'` from Step 5).
|
||||
5. Add after `onEndReachedThreshold`:
|
||||
```ts
|
||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||
onViewableItemsChanged={onViewableItemsChangedRef}
|
||||
```
|
||||
|
||||
Note: `react-native-video` is already installed in this project. Confirmed: `components/NativeVideoPlayer.tsx` line 14 has `import Video, { VideoRef } from "react-native-video"`. No additional installation needed.
|
||||
|
||||
- [ ] **Step 7: TypeScript check**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 8: Run the app and verify visually**
|
||||
|
||||
```bash
|
||||
expo start --port 8082
|
||||
```
|
||||
|
||||
Check:
|
||||
1. Homepage renders two-column layout for normal cards
|
||||
2. Last card is full-width with 16:9 thumbnail
|
||||
3. Scrolling to the last card starts muted video after a brief delay
|
||||
4. Thumbnail fades out when video is ready
|
||||
5. Scrolling away from the big card pauses the video
|
||||
6. After loading more items, the entire row list is recomputed: the previously-last item now appears in a normal pair row, and the new last item appears as the big card at the bottom
|
||||
7. When the number of normal (non-big) items is odd, the last pair row shows a single card on the left with an empty right slot
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add app/index.tsx
|
||||
git commit -m "feat: refactor homepage FlatList for big-card layout with viewport autoplay"
|
||||
```
|
||||
@@ -1,90 +0,0 @@
|
||||
# Player UI Redesign Spec
|
||||
|
||||
## Goal
|
||||
重写播放器 UI:统一自定义控制层(点击显示/3s 自动隐藏),将热度进度条和缩略图预览整合进播放器内部,删除播放器下方独立的 HeatProgressBar。
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
+------------------------------------------+
|
||||
│ [pip] │ ← 顶部栏(半透明黑底渐变)
|
||||
│ │
|
||||
│ [▶/⏸] │ ← 中心播放/暂停按钮
|
||||
│ │
|
||||
│ [缩略图 + 时间] │ ← 拖拽时浮出,紧贴进度条上方
|
||||
│ [00:12] [════════●═══] [10:23] [HD] [⛶] │ ← 底部栏(半透明黑底渐变)
|
||||
+------------------------------------------+
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files Changed
|
||||
|
||||
| File | Action | Notes |
|
||||
|---|---|---|
|
||||
| `components/NativeVideoPlayer.tsx` | Rewrite | 全量自定义控制层,整合热度图逻辑 |
|
||||
| `components/VideoPlayer.tsx` | Modify | 新增 bvid/cid props,向下透传 |
|
||||
| `app/video/[bvid].tsx` | Modify | 删除 HeatProgressBar 及相关 state |
|
||||
| `components/HeatProgressBar.tsx` | Delete | 逻辑搬入 NativeVideoPlayer |
|
||||
|
||||
### NativeVideoPlayer Props
|
||||
|
||||
```ts
|
||||
interface Props {
|
||||
playData: PlayUrlResponse | null;
|
||||
qualities: { qn: number; desc: string }[];
|
||||
currentQn: number;
|
||||
onQualityChange: (qn: number) => void;
|
||||
onFullscreen: () => void;
|
||||
onMiniPlayer?: () => void;
|
||||
style?: object;
|
||||
bvid?: string; // NEW: for heatmap + thumbnail
|
||||
cid?: number; // NEW: for heatmap + thumbnail
|
||||
}
|
||||
```
|
||||
|
||||
`onProgress` / `seekTo` props 删除(进度状态内部管理)
|
||||
|
||||
### Internal State
|
||||
|
||||
| State | Type | Description |
|
||||
|---|---|---|
|
||||
| `resolvedUrl` | string? | async MPD/durl URI |
|
||||
| `showControls` | boolean | 控制层可见性 |
|
||||
| `paused` | boolean | 播放/暂停 |
|
||||
| `currentTime` | number | 当前播放进度(秒) |
|
||||
| `duration` | number | 视频总时长 |
|
||||
| `isSeeking` | boolean | 是否正在拖拽进度条 |
|
||||
| `touchX` | number? | 拖拽时的 x 坐标(相对进度条左端) |
|
||||
| `heatSegments` | number[] | 热度图归一化数据 |
|
||||
| `shots` | VideoShotData? | 缩略图雪碧图数据 |
|
||||
|
||||
### Control Layer Behavior
|
||||
|
||||
- **点击视频区域** → toggle `showControls`,重置 3s 隐藏定时器
|
||||
- **拖拽进度条** → `isSeeking=true`,暂停定时器;松手 → seek,`isSeeking=false`,重置定时器
|
||||
- **点击播放/暂停** → toggle `paused`,重置定时器
|
||||
- **切换清晰度** → Modal 显示时不自动隐藏控制层
|
||||
|
||||
### Progress Bar Layout (bottom bar)
|
||||
|
||||
```
|
||||
[▶/⏸] [00:12] [track with heatmap + ball] [10:23] [HD] [⛶]
|
||||
```
|
||||
|
||||
- 热度图色段 + 已播进度半透明遮罩(同 HeatProgressBar 现有实现)
|
||||
- 拖拽时进度条 ball 放大至 16px,并在上方显示缩略图 + 时间
|
||||
- 底部栏背景:`linear-gradient(transparent → rgba(0,0,0,0.7))`(使用 expo-linear-gradient)
|
||||
|
||||
### Thumbnail Preview
|
||||
|
||||
- 触发:`isSeeking=true` 且 `shots` 数据已加载
|
||||
- 位置:进度条上方,左右不超出边界
|
||||
- 内容:雪碧图裁切(同 HeatProgressBar 现有实现)+ 时间文字
|
||||
|
||||
## Deleted
|
||||
|
||||
- `components/HeatProgressBar.tsx`
|
||||
- `[bvid].tsx` 中的 `currentTime`、`duration`、`seekCmd` state
|
||||
- `[bvid].tsx` 中的 `<HeatProgressBar>` JSX 及 import
|
||||
- `VideoPlayer.tsx` 中的 `onProgress`、`seekTo` props
|
||||
@@ -1,118 +0,0 @@
|
||||
# Big Video Card — 设计文档
|
||||
|
||||
**日期:** 2026-03-11
|
||||
**状态:** 已批准
|
||||
|
||||
## 需求
|
||||
|
||||
首页热门视频双列列表中,每次加载更多后,**列表最末尾的最后一个卡片**以"大卡片"形式呈现,占据两列全宽空间。大卡片在滚动进入视口时静音自动播放视频内容,默认使用最低画质(qn=16,360P)。
|
||||
|
||||
## 数据结构
|
||||
|
||||
### 行类型(`utils/videoRows.ts`)
|
||||
|
||||
```ts
|
||||
type NormalRow = { type: 'pair'; left: VideoItem; right: VideoItem | null };
|
||||
type BigRow = { type: 'big'; item: VideoItem };
|
||||
type ListRow = NormalRow | BigRow;
|
||||
```
|
||||
|
||||
### 行化规则
|
||||
|
||||
- 除最后一项外,所有视频两两配对为 `NormalRow`
|
||||
- 最后一项始终为 `BigRow`
|
||||
- 空列表返回 `[]`
|
||||
- 仅 1 个 item 时直接生成 `BigRow`
|
||||
|
||||
示例:
|
||||
```
|
||||
videos = [A, B, C, D, E]
|
||||
rows = [
|
||||
{ type: 'pair', left: A, right: B },
|
||||
{ type: 'pair', left: C, right: D },
|
||||
{ type: 'big', item: E },
|
||||
]
|
||||
```
|
||||
|
||||
## BigVideoCard 组件
|
||||
|
||||
### 文件:`components/BigVideoCard.tsx`
|
||||
|
||||
### Props
|
||||
|
||||
```ts
|
||||
interface BigVideoCardProps {
|
||||
item: VideoItem;
|
||||
isVisible: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 层次结构
|
||||
|
||||
```
|
||||
BigVideoCard (TouchableOpacity)
|
||||
├── 封面图 (Image, 16:9 全宽)
|
||||
├── 视频播放器 (react-native-video <Video>, position: absolute, isVisible 时加载;直接使用 <Video> 而非 NativeVideoPlayer,因为不需要控制条/热力图等全功能组件)
|
||||
│ └── muted, autoplay, qn=16
|
||||
├── 封面遮罩 (Animated.View, opacity 1→0 淡出)
|
||||
└── 底部信息栏
|
||||
├── 标题 (numberOfLines=2)
|
||||
├── 播放量 + 弹幕数
|
||||
└── UP主名称
|
||||
```
|
||||
|
||||
### 生命周期
|
||||
|
||||
| 状态 | 行为 |
|
||||
|---|---|
|
||||
| `isVisible=false` | 仅渲染封面图,不请求播放 URL |
|
||||
| `isVisible=true` | 调用 `getPlayUrl(bvid, cid, 16)`,加载 NativeVideoPlayer |
|
||||
| 视频可播放(`canplay` 事件) | 封面图淡出(Animated,300ms) |
|
||||
| `isVisible=false`(离开视口) | 注入 JS 暂停视频,封面恢复 |
|
||||
|
||||
### 画质
|
||||
|
||||
`qn=16`(360P),B 站最低公开档,无需登录,适合静音自动播放场景。
|
||||
|
||||
## FlatList 集成(`app/index.tsx`)
|
||||
|
||||
### 改动
|
||||
|
||||
| 属性 | 改动前 | 改动后 |
|
||||
|---|---|---|
|
||||
| `data` | `videos: VideoItem[]` | `rows: ListRow[]` |
|
||||
| `numColumns` | `2` | `1` |
|
||||
| `columnWrapperStyle` | 存在 | 删除 |
|
||||
| `renderItem` | 单卡片 | 按 `row.type` 分支渲染 |
|
||||
|
||||
### 视口检测
|
||||
|
||||
```ts
|
||||
const viewabilityConfig = {
|
||||
itemVisiblePercentThreshold: 50,
|
||||
};
|
||||
|
||||
// useRef 包裹,避免 FlatList prop 变更警告
|
||||
const onViewableItemsChanged = useRef(({ viewableItems }) => {
|
||||
const bigItem = viewableItems.find(v => v.item.type === 'big');
|
||||
setVisibleBigKey(bigItem ? bigItem.key : null);
|
||||
}).current;
|
||||
```
|
||||
|
||||
`BigVideoCard` 接收 `isVisible={row.item.bvid === visibleBigKey}`。
|
||||
|
||||
## 新增 / 修改文件
|
||||
|
||||
| 文件 | 操作 |
|
||||
|---|---|
|
||||
| `utils/videoRows.ts` | 新增 — 行化函数与类型 |
|
||||
| `components/BigVideoCard.tsx` | 新增 — 大卡片组件 |
|
||||
| `app/index.tsx` | 修改 — FlatList 重构 |
|
||||
| `services/bilibili.ts` | 无需修改(`getPlayUrl` 已支持 qn 参数) |
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 搜索、动态、DASH 播放等其他功能
|
||||
- 大卡片的点击行为与普通卡片一致(跳转视频详情页)
|
||||
- 画质切换 UI(大卡片固定 qn=16)
|
||||
154
feature.md
154
feature.md
@@ -1,154 +0,0 @@
|
||||
# 代码审查:优化建议与 Bug 清单
|
||||
|
||||
## 严重 Bug
|
||||
|
||||
### 1. ~~useComments 竞态条件~~ [已修复]
|
||||
- **文件**: `hooks/useComments.ts`
|
||||
- **问题**: `load()` 依赖 `[aid, page, loading, hasMore]`,`setPage(p => p + 1)` 触发新的 load 回调,可能重复请求或跳页
|
||||
- **修复**: 改用 `pageRef` + `loadingRef` 避免依赖循环
|
||||
|
||||
### 2. ~~LoginModal 错误处理缺失~~ [已修复]
|
||||
- **文件**: `components/LoginModal.tsx`
|
||||
- **问题**: `login(result.cookie, '', '')` 传入空 uid/username;`getUserInfo()` 调用无错误处理
|
||||
- **修复**: 添加 try-catch 包裹登录+拉取用户信息流程
|
||||
|
||||
### 3. ~~NativeVideoPlayer loadPvData 未捕获 Promise 异常~~ [已修复]
|
||||
- **文件**: `components/NativeVideoPlayer.tsx`
|
||||
- **问题**: `loadPvData().then(...)` 没有 `.catch()`,且原来的 try-catch 无法捕获异步 Promise
|
||||
- **修复**: 改为 `.then().catch()` 链式调用
|
||||
|
||||
### 4. BigVideoCard PanResponder 闭包陈旧
|
||||
- **文件**: `components/BigVideoCard.tsx`
|
||||
- **问题**: `useMemo` 依赖为 `[]`,但内部使用 `currentTimeRef`、`durationRef`、`screenWRef`,重构时容易引入 bug
|
||||
- **状态**: 当前使用 ref 所以不会出错,但属于脆弱模式
|
||||
|
||||
## 性能问题
|
||||
|
||||
### 5. ~~index.tsx renderItem 未稳定~~ [已修复]
|
||||
- **文件**: `app/index.tsx`
|
||||
- **问题**: `renderItem` 依赖 `[visibleBigKey]`,每次可见项变化都重建回调
|
||||
- **修复**: 改用 `visibleBigKeyRef` + 空依赖 `useCallback`
|
||||
|
||||
### 6. BigVideoCard 视频预加载无取消机制
|
||||
- **文件**: `components/BigVideoCard.tsx`
|
||||
- **问题**: 快速滑动时,所有可见卡片同时发起 `getPlayUrl` 请求,无取消/去抖
|
||||
- **修复**: 增加可见性停留阈值或 AbortController
|
||||
|
||||
### 7. DanmakuList 动画值频繁创建
|
||||
- **文件**: `components/DanmakuList.tsx`
|
||||
- **问题**: 每次 drip interval 都创建新的 `Animated.Value`,弹幕密集时影响性能
|
||||
- **修复**: 使用对象池复用 Animated.Value
|
||||
|
||||
### 8. LivePulse 动画堆叠
|
||||
- **文件**: `components/LivePulse.tsx`
|
||||
- **问题**: 组件快速卸载/重新挂载时,`Animated.loop()` 动画可能堆叠
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 9. 缺少全局错误边界
|
||||
- **文件**: `app/_layout.tsx`
|
||||
- **问题**: 没有 React Error Boundary,任何组件抛异常会导致白屏
|
||||
- **修复**: 在根布局包裹 ErrorBoundary 组件
|
||||
|
||||
### 10. ~~WBI Keys 缓存无过期~~ [已修复]
|
||||
- **文件**: `services/bilibili.ts`
|
||||
- **问题**: `wbiKeys` 模块级变量缓存后永不过期,B站 WBI 密钥每日更换
|
||||
- **修复**: 添加 12 小时 TTL,过期后自动刷新;网络错误时回退到陈旧缓存
|
||||
|
||||
### 11. ~~getWbiKeys 无容错~~ [已修复]
|
||||
- **文件**: `services/bilibili.ts`
|
||||
- **问题**: 直接访问 `res.data.data.wbi_img`,结构不匹配时直接崩溃
|
||||
- **修复**: 添加可选链检查 + try-catch + 陈旧缓存回退
|
||||
|
||||
### 12. ~~Cookie 解析脆弱~~ [已修复]
|
||||
- **文件**: `services/bilibili.ts`
|
||||
- **问题**: `split(';')[0].replace('SESSDATA=', '')` 假设 SESSDATA 是第一个 cookie 属性
|
||||
- **修复**: 改为 `.find()` 查找包含 `SESSDATA=` 的 part
|
||||
|
||||
### 13. ~~useVideoDetail 静默吞错~~ [已修复]
|
||||
- **文件**: `hooks/useVideoDetail.ts`
|
||||
- **问题**: `.catch(() => {})` 吞掉重新获取播放数据时的错误,用户无感知
|
||||
- **修复**: 改为 `console.warn` 输出
|
||||
|
||||
## 代码质量
|
||||
|
||||
### 14. ~~残留调试代码~~ [已修复]
|
||||
- **文件**: `components/NativeVideoPlayer.tsx`
|
||||
- **问题**: `console.log(r, "ddddddddddddd")` 和未使用的 `axios` 导入
|
||||
- **修复**: 已移除
|
||||
|
||||
### 15. 类型安全问题
|
||||
- **文件**: `app/index.tsx`
|
||||
- **问题**: `router.push(\`/video/${bvid}\` as any)` 使用 `as any` 绕过类型检查
|
||||
- **修复**: 正确定义路由类型
|
||||
|
||||
### 16. 未使用的导入
|
||||
- **文件**: `components/VideoPlayer.tsx`
|
||||
- **问题**: `Paths` 从 `expo-file-system` 导入但未使用
|
||||
|
||||
### 17. 魔法数字散落
|
||||
- **文件**: 多处
|
||||
- **问题**: `SWIPE_SECONDS = 90`、`HIDE_DELAY = 3000`、`DRIP_INTERVAL = 250` 等硬编码
|
||||
- **修复**: 统一到 constants 文件
|
||||
|
||||
## API 使用
|
||||
|
||||
### 18. 无请求去重
|
||||
- **文件**: `services/bilibili.ts`
|
||||
- **问题**: BigVideoCard 和 NativeVideoPlayer 可能同时为同一视频发起 `getPlayUrl` 请求
|
||||
- **修复**: 实现请求去重(相同参数复用 Promise)
|
||||
|
||||
### 19. 无请求超时重试
|
||||
- **文件**: `services/bilibili.ts`
|
||||
- **问题**: 设置了 `timeout: 10000` 但无重试逻辑,弱网下直接失败
|
||||
|
||||
## UI/UX
|
||||
|
||||
### 20. ~~MiniPlayer 拖拽无边界~~ [已修复]
|
||||
- **文件**: `components/MiniPlayer.tsx`
|
||||
- **问题**: 拖拽可将迷你播放器移出屏幕外,无边界限制
|
||||
- **修复**: 释放时 clamp 到屏幕范围内,超出部分用 spring 动画弹回
|
||||
|
||||
### 21. 无网络状态检测
|
||||
- **问题**: 离线时所有 API 静默失败,用户无反馈
|
||||
- **修复**: 使用 `@react-native-community/netinfo` 检测网络并提示
|
||||
|
||||
### 22. BigVideoCard 预加载无 Loading 态
|
||||
- **问题**: 视频 URL 加载期间画面冻结,无骨架屏或加载动画
|
||||
|
||||
## 安全
|
||||
|
||||
### 23. SESSDATA 明文存储
|
||||
- **文件**: `services/bilibili.ts` / `store/authStore.ts`
|
||||
- **问题**: Cookie 存储在 AsyncStorage(明文),设备被攻破可泄露
|
||||
- **修复**: 使用 `expo-secure-store` 存储敏感凭证
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 项目 | 状态 |
|
||||
|--------|------|------|
|
||||
| **P0** | #1 竞态条件 | 已修复 |
|
||||
| **P0** | #3 Promise 未捕获 | 已修复 |
|
||||
| **P0** | #14 调试代码 | 已修复 |
|
||||
| **P0** | #10 WBI 过期 | 已修复 |
|
||||
| **P1** | #2 登录错误处理 | 已修复 |
|
||||
| **P1** | #5 renderItem 稳定化 | 已修复 |
|
||||
| **P1** | #11 getWbiKeys 容错 | 已修复 |
|
||||
| **P1** | #12 Cookie 解析 | 已修复 |
|
||||
| **P1** | #13 静默吞错 | 已修复 |
|
||||
| **P2** | #20 MiniPlayer 边界 | 已修复 |
|
||||
| **P1** | #9 错误边界 | 待修复 |
|
||||
| **P2** | #6 预加载取消 | 待修复 |
|
||||
| **P2** | #18 请求去重 | 待修复 |
|
||||
| **P2** | #21 网络检测 | 待修复 |
|
||||
| **P3** | #4 闭包陈旧 | 暂不修复(ref模式可用) |
|
||||
| **P3** | #7 动画池化 | 待修复 |
|
||||
| **P3** | #8 动画堆叠 | 待修复 |
|
||||
| **P3** | #15 类型安全 | 待修复 |
|
||||
| **P3** | #16 未使用导入 | 待修复 |
|
||||
| **P3** | #17 魔法数字 | 待修复 |
|
||||
| **P3** | #19 超时重试 | 待修复 |
|
||||
| **P3** | #22 Loading 态 | 待修复 |
|
||||
| **P3** | #23 安全存储 | 待修复 |
|
||||
106
livePlan.md
106
livePlan.md
@@ -1,106 +0,0 @@
|
||||
# B站直播详情页 实现计划
|
||||
|
||||
## Context
|
||||
现有应用已有直播列表(LiveCard + useLiveList),但点击卡片只是用 `Linking.openURL` 跳转到外部浏览器,没有应用内直播详情页。本次目标是实现一个完整的 in-app 直播详情页,包括 HLS 播放器、主播信息、房间简介。
|
||||
|
||||
---
|
||||
|
||||
## 直播详情页功能需求(MVP)
|
||||
|
||||
| 功能 | 说明 |
|
||||
|---|---|
|
||||
| 直播播放器 | HLS (m3u8) 流,`react-native-video` 播放,支持全屏 |
|
||||
| 房间信息 | 标题、分类(父/子分区)、在线人数 |
|
||||
| 主播信息 | 头像、昵称 |
|
||||
| 关注按钮 | 展示样式,不调 API(bili_jct 不在当前登录流程中) |
|
||||
| 离线状态 | 若 `live_status !== 1` 或无流 URL,显示"暂未开播" |
|
||||
|
||||
**跳过(Post-MVP):** 实时弹幕WebSocket、质量切换、送礼、直播间聊天
|
||||
|
||||
---
|
||||
|
||||
## Bilibili 直播 API
|
||||
|
||||
| 函数 | 接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `getLiveRoomDetail(roomId)` | `LIVE_BASE/room/v1/Room/get_info` | 房间详情(标题、分区、live_status、在线人数) |
|
||||
| `getLiveAnchorInfo(roomId)` | `LIVE_BASE/live_user/v1/UserInfo/get_anchor_in_room` | 主播信息(uid、uname、face) |
|
||||
| `getLiveStreamUrl(roomId)` | `LIVE_BASE/xlive/web-room/v2/index/getRoomPlayInfo` | 获取 HLS/FLV 流 URL |
|
||||
|
||||
**流 URL 提取路径:**
|
||||
```
|
||||
data.playurl_info.playurl.stream[]
|
||||
→ protocol_name === 'http_hls'
|
||||
→ format[].format_name === 'fmp4' or 'ts'
|
||||
→ codec[].codec_name === 'avc'
|
||||
→ url_info[0].host + codec.base_url
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单(按依赖顺序)
|
||||
|
||||
### 新增文件
|
||||
1. `livePlan.md` — 本文件
|
||||
2. `services/types.ts` — 添加 `LiveRoomDetail`、`LiveAnchorInfo`、`LiveStreamInfo` 类型
|
||||
3. `services/bilibili.ts` — 添加 `getLiveRoomDetail`、`getLiveAnchorInfo`、`getLiveStreamUrl`
|
||||
4. `hooks/useLiveDetail.ts` — 直播详情 hook(并行拉取房间信息+主播信息)
|
||||
5. `components/LivePlayer.tsx` — 直播播放器(web 提示 / native 用 react-native-video HLS)
|
||||
6. `app/live/_layout.tsx` — Stack layout(同 `app/video/_layout.tsx`)
|
||||
7. `app/live/[roomId].tsx` — 直播详情页主文件
|
||||
|
||||
### 修改文件
|
||||
8. `dev-proxy.js` — 添加 `/bilibili-live` 代理路由(修复 web 端直播 API)
|
||||
9. `app/_layout.tsx` — 注册 `live` Stack.Screen(动画同 video)
|
||||
10. `app/index.tsx` — 替换 `Linking.openURL` 为 `router.push('/live/...')`,给直播 tab 的卡片加 `onPress`
|
||||
|
||||
---
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
app/live/[roomId].tsx
|
||||
TopBar (返回 + 标题)
|
||||
LivePlayer
|
||||
├── web: "请在手机端观看直播"
|
||||
└── native: <Video source={{uri: hlsUrl}} /> + 简单控制栏 (LIVE 标牌 / 全屏)
|
||||
ScrollView
|
||||
房间标题
|
||||
在线人数 + LivePulse
|
||||
分区标签 (parent_area_name > area_name)
|
||||
分割线
|
||||
主播行 (头像 + uname + 关注按钮)
|
||||
房间描述文本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
LiveCard onPress → router.push('/live/[roomId]')
|
||||
useLiveDetail(roomId)
|
||||
Promise.all([getLiveRoomDetail, getLiveAnchorInfo])
|
||||
if live_status===1 → getLiveStreamUrl → hlsUrl
|
||||
→ LivePlayer <Video source={{uri: hlsUrl}} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **`roomId` 类型**:`useLocalSearchParams` 返回 string,需 `parseInt(roomId, 10)`
|
||||
2. **router.push 类型**:使用 `as any` 规避 TypeScript 动态路由报错
|
||||
3. **流 URL 提取**:用可选链 (`?.`) 兜底,失败时返回 `{ hlsUrl: '', flvUrl: '', qn: 0 }`,让 LivePlayer 显示离线状态
|
||||
4. **live 无 onEnd**:直播流没有结束,不处理 `onEnd` 事件
|
||||
5. **web 端 HLS**:`<video>` 标签仅 Safari 原生支持 m3u8,Chrome 不支持;web 端显示提示文字即可
|
||||
|
||||
---
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. `expo start` → 扫码打开 → 首页直播 tab → 点击直播卡片 → 进入详情页
|
||||
2. 验证:播放器加载 → 视频流开始缓冲 → 显示主播信息与房间标题
|
||||
3. 验证:点击全屏 → 横屏全屏播放
|
||||
4. 验证:关闭直播间(或选一个离线房间)→ 显示"暂未开播"占位图
|
||||
5. web 端:`expo start --web` → 直播详情页显示"请在手机端观看直播"
|
||||
BIN
public/p1.jpg
Normal file
BIN
public/p1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 235 KiB |
BIN
public/p2.jpg
Normal file
BIN
public/p2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
BIN
public/p3.jpg
Normal file
BIN
public/p3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
BIN
public/p4.jpg
Normal file
BIN
public/p4.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
public/p5.jpg
Normal file
BIN
public/p5.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
BIN
public/p6.jpg
Normal file
BIN
public/p6.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Reference in New Issue
Block a user