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:
Developer
2026-03-20 12:03:24 +08:00
parent 5ec4c5a6be
commit ff5860d3c7
25 changed files with 680 additions and 2473 deletions

68
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Bug 报告
description: 报告一个 Bug
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
感谢你提交 Bug 报告!请尽量填写完整信息,帮助我们更快定位问题。
- type: dropdown
id: platform
attributes:
label: 运行平台
options:
- AndroidDev Build
- AndroidExpo Go
- iOSDev Build
- iOSExpo 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

View 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
View File

@@ -0,0 +1,30 @@
## 改动说明
> 简要描述本次 PR 做了什么
## 改动类型
- [ ] Bug 修复
- [ ] 新功能
- [ ] 重构(不改变功能)
- [ ] 文档更新
- [ ] 其他:
## 关联 Issue
> 关闭 #Issue 编号)
## 测试平台
- [ ] AndroidDev Build
- [ ] AndroidExpo Go
- [ ] iOS
- [ ] Web
## 截图 / 录屏(如适用)
## 注意事项
- [ ] 代码中无硬编码账号信息SESSDATA、uid 等)
- [ ] Commit 信息符合 Conventional Commits 规范
- [ ] 已在本地测试通过

6
.gitignore vendored
View File

@@ -42,3 +42,9 @@ yarn-error.*
# git worktrees # git worktrees
.worktrees/ .worktrees/
# Internal AI instructions & planning docs
CLAUDE.md
docs/
feature.md
livePlan.md

50
CHANGELOG.md Normal file
View 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
View File

@@ -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-videoDASH/HLS/MP4|
| 视频播放(首页 BigCard | react-native-videoDASH MPD内联自动播放 |
| 视频播放WebView 降级) | react-native-webviewNativeVideoPlayer |
| 页面滑动 | react-native-pager-view首页热门/直播 Tab |
| 图标 | @expo/vector-iconsIonicons |
## 目录结构
```
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 videoJS 注入 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 # proxyImageUrlWeb 代理绕防盗链)/ coverImageUrl清晰度参数
dash.ts # buildDashMpdUri将 B 站 DASH 接口数据转换为 MPD 文件 URI
danmaku.ts # danmakuColorToCss弹幕颜色转换
```
## 路由结构
- `/` → 首页Stack 根页面PagerView 热门/直播 Tab
- `/video/[bvid]` → 视频详情Stack pushslide_from_right 动画)
- `/live/[roomId]` → 直播详情Stack pushslide_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` 直接播放 DASHfnval=16buildDashMpdUri 转 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 DASHfnval=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
// DASHreact-native-videodev build / 生产包)
params: { bvid, cid, qn: 112, fnval: 16, platform: 'pc' }
// → 返回 dash 字段,需 buildDashMpdUri 转为本地 MPD
// MP4WebView 降级 / 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-videoDASH MPD静音自动播放
+ PanResponder 手势快进
+ 封面 Animated.View 淡出过渡
```
### 直播LivePlayer
```
LivePlayer
└── react-native-videoHLS 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-videoDASH/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
View 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
View 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
View 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*
---
[![React Native](https://img.shields.io/badge/React_Native-0.83-61DAFB?logo=react)](https://reactnative.dev)
[![Expo](https://img.shields.io/badge/Expo-SDK_55-000020?logo=expo)](https://expo.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript)](https://www.typescriptlang.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/Platform-Android%20%7C%20iOS%20%7C%20Web-lightgrey)](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
View 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*
---
[![React Native](https://img.shields.io/badge/React_Native-0.83-61DAFB?logo=react)](https://reactnative.dev)
[![Expo](https://img.shields.io/badge/Expo-SDK_55-000020?logo=expo)](https://expo.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript)](https://www.typescriptlang.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/Platform-Android%20%7C%20iOS%20%7C%20Web-lightgrey)](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 · WebExpo 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-videoDASH MPD / HLS / MP4 |
| 降级播放 | react-native-webviewHTML5 video 注入) |
| 页面滑动 | react-native-pager-view |
| 图标 | @expo/vector-iconsIonicons |
---
## 快速开始
### 方式一Expo Go5 分钟,无需编译)
> 部分清晰度受限,视频播放降级为 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>

View File

@@ -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字节包头
```
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ 03 │ 45 │ 67 │ 811 │ 1215 │
│ 包总长度 │ 头部长度 │ 协议版本 │ 操作码 │ 序列号 │
│ 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 | 是 | 推荐 3brotli或 2zlib |
| 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` | 2zlib | 3brotli或 2 | 先用 2brotli 需 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=2zlibpako 已支持。
---
## 八、弹幕消息 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舰长
```

View File

@@ -1,196 +0,0 @@
# Bug Report
> 最后更新2026-03-10
---
## CRITICAL
### BUG-01 · `video.stat` 空指针崩溃
**文件**: `app/video/[bvid].tsx` 第 7275、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` 第 3133 行
**类型**: Logic Bug
`useComments` hook 内部没有重置机制。当用户从视频 Aaid=123已加载 3 页)跳转到视频 Baid=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` 第 108112 行
**类型**: UX
按钮渲染条件为 `!cmtLoading && comments.length > 0`。首次进入视频详情时,评论需要用户手动点击"加载更多"触发,但在 `loadComments``useEffect` 自动调用(第 32 行)完成前,按钮不存在。若自动加载失败,用户无法重试。
---
### BUG-11 · `useVideoDetail` 登录态变化时不重置 loading
**文件**: `hooks/useVideoDetail.ts` 第 5357 行
**类型**: 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 | 登录态变化时不显示重新加载状态 |

View File

@@ -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 97102)
- [ ] **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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
```
- [ ] **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 URIAndroid降级使用 durl MP4iOS
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 buildreact-native-video 是 native 模块)
2. 打开任意视频详情页
3. 确认播放器加载且**有声音有画面**
4. 点击右上角清晰度按钮,检查可选项是否包含 1080P80或更高
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 上视频清晰度列表出现 1080Pqn=80及以上选项
- [ ] 切换清晰度后视频带音频正常播放
- [ ] iOS 仍可正常播放720P durl 路径)
- [ ] `npx tsc --noEmit` 无类型错误

View File

@@ -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

View File

@@ -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"
```

View File

@@ -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

View File

@@ -1,118 +0,0 @@
# Big Video Card — 设计文档
**日期:** 2026-03-11
**状态:** 已批准
## 需求
首页热门视频双列列表中,每次加载更多后,**列表最末尾的最后一个卡片**以"大卡片"形式呈现占据两列全宽空间。大卡片在滚动进入视口时静音自动播放视频内容默认使用最低画质qn=16360P
## 数据结构
### 行类型(`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` 事件) | 封面图淡出Animated300ms |
| `isVisible=false`(离开视口) | 注入 JS 暂停视频,封面恢复 |
### 画质
`qn=16`360PB 站最低公开档,无需登录,适合静音自动播放场景。
## 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

View File

@@ -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 安全存储 | 待修复 |

View File

@@ -1,106 +0,0 @@
# B站直播详情页 实现计划
## Context
现有应用已有直播列表LiveCard + useLiveList但点击卡片只是用 `Linking.openURL` 跳转到外部浏览器,没有应用内直播详情页。本次目标是实现一个完整的 in-app 直播详情页,包括 HLS 播放器、主播信息、房间简介。
---
## 直播详情页功能需求MVP
| 功能 | 说明 |
|---|---|
| 直播播放器 | HLS (m3u8) 流,`react-native-video` 播放,支持全屏 |
| 房间信息 | 标题、分类(父/子分区)、在线人数 |
| 主播信息 | 头像、昵称 |
| 关注按钮 | 展示样式,不调 APIbili_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 原生支持 m3u8Chrome 不支持web 端显示提示文字即可
---
## 验证方式
1. `expo start` → 扫码打开 → 首页直播 tab → 点击直播卡片 → 进入详情页
2. 验证:播放器加载 → 视频流开始缓冲 → 显示主播信息与房间标题
3. 验证:点击全屏 → 横屏全屏播放
4. 验证:关闭直播间(或选一个离线房间)→ 显示"暂未开播"占位图
5. web 端:`expo start --web` → 直播详情页显示"请在手机端观看直播"

BIN
public/p1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

BIN
public/p2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

BIN
public/p3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
public/p4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
public/p5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

BIN
public/p6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB