mirror of
https://gh-proxy.org/https://github.com/tiajinsha/JKVideo
synced 2026-07-08 07:28:37 +08:00
设置页面
This commit is contained in:
183
CLAUDE.md
183
CLAUDE.md
@@ -1,50 +1,106 @@
|
||||
# reactBilibiliApp — 项目架构与技术要点
|
||||
|
||||
## 项目概述
|
||||
仿 B 站 React Native 客户端,使用 Expo SDK 55 + expo-router,调用 Bilibili 官方 Web API 获取热门视频、视频详情、评论及扫码登录。
|
||||
仿 B 站 React Native 客户端,使用 Expo SDK 55 + expo-router,调用 Bilibili 官方 Web API 获取热门视频、直播、视频详情、弹幕、评论、搜索及扫码登录。
|
||||
|
||||
## 技术栈
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 框架 | React Native 0.83 + Expo SDK 55 |
|
||||
| 路由 | expo-router v4(文件系统路由) |
|
||||
| 路由 | expo-router v4(文件系统路由,Stack 导航) |
|
||||
| 状态管理 | Zustand |
|
||||
| 网络请求 | Axios |
|
||||
| 本地存储 | @react-native-async-storage/async-storage |
|
||||
| 视频播放 | react-native-webview(内嵌 HTML5 video) |
|
||||
| 视频播放(详情页/直播) | 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 # 根布局:Tab 导航 + 启动时恢复登录态
|
||||
index.tsx # 首页:热门视频瀑布流(双列 FlatList)
|
||||
_layout.tsx # 根布局:Stack 导航 + 启动时恢复登录态/下载列表/设置
|
||||
# 全局挂载 <MiniPlayer />
|
||||
index.tsx # 首页:PagerView 切换热门/直播;绝对定位悬浮导航栏
|
||||
video/
|
||||
_layout.tsx # Stack 导航(无头部)
|
||||
[bvid].tsx # 视频详情页(播放 + 简介 + 评论)
|
||||
[bvid].tsx # 视频详情页(播放 + 简介/评论/弹幕 三 Tab + 推荐视频 + 下载)
|
||||
live/
|
||||
_layout.tsx # Stack 导航(无头部)
|
||||
[roomId].tsx # 直播详情页(HLS/FLV 播放 + 简介/弹幕 Tab)
|
||||
search.tsx # 搜索页(关键词搜索视频)
|
||||
downloads.tsx # 下载管理页(已下载视频列表)
|
||||
settings.tsx # 设置页(封面图清晰度 + 退出登录)
|
||||
|
||||
components/
|
||||
VideoCard.tsx # 视频卡片(封面、标题、UP主、播放量)
|
||||
VideoPlayer.tsx # 视频播放器入口:web 用 <video>,native 用 NativeVideoPlayer
|
||||
NativeVideoPlayer.tsx# WebView 内嵌 HTML5 video 播放,用 JS 注入 src 避免 URL 特殊字符问题
|
||||
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 # 热门视频分页加载(支持下拉刷新、上拉加载更多)
|
||||
useVideoDetail.ts # 视频详情 + 获取播放流 URL
|
||||
useComments.ts # 评论分页加载
|
||||
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 + 持久化)
|
||||
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(弹幕颜色转换)
|
||||
```
|
||||
|
||||
## 路由结构
|
||||
- `/` → 首页(Tab)
|
||||
- `/video/[bvid]` → 视频详情(Stack,从 Tab 内 push)
|
||||
- `video` Tab 在导航栏中隐藏(`href: null`)
|
||||
- `/` → 首页(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 关键点
|
||||
|
||||
@@ -56,64 +112,115 @@ utils/
|
||||
| 函数 | 接口 | 说明 |
|
||||
|---|---|---|
|
||||
| `getPopularVideos(pn)` | `/x/web-interface/popular` | 热门视频,每页 20 条 |
|
||||
| `getVideoDetail(bvid)` | `/x/web-interface/view` | 视频详情 |
|
||||
| `getPlayUrl(bvid, cid)` | `/x/player/playurl` | 获取播放流 |
|
||||
| `getComments(aid, pn)` | `/x/v2/reply` | 评论列表,sort=2(热评) |
|
||||
| `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: 0` + `platform: 'html5'` → 返回 **MP4** 格式(`durl[0].url`)
|
||||
- `fnval: 1` → FLV,**不可用**(HTML5 video 不支持,WebView 无法播放)
|
||||
- `fnval: 16` → DASH,结构不同(`dash` 字段,非 `durl`),需单独实现
|
||||
- `qn: 64` = 720P,未登录时 B 站可能降级到较低清晰度
|
||||
- `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 → NativeVideoPlayer (WebView)
|
||||
└── HTML 模板 + JS 注入 src
|
||||
document.getElementById('v').src = JSON.stringify(uri)
|
||||
└── native → react-native-video (DASH MPD / MP4)
|
||||
支持多清晰度切换、弹幕覆盖层(DanmakuOverlay)
|
||||
降级:NativeVideoPlayer (WebView + JS 注入)
|
||||
```
|
||||
|
||||
**为什么用 WebView 而非 expo-av:**
|
||||
- `expo-av` 需要 development build(native 编译),在 Expo Go 中报 `Cannot find native module 'ExponentAV'`
|
||||
- `react-native-webview` 在 Expo Go 中开箱即用
|
||||
- 用 `JSON.stringify(uri)` 注入 src,防止 URL 中 `&`、`=` 等特殊字符破坏 HTML 属性
|
||||
### 首页 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`
|
||||
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()` 恢复登录态
|
||||
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`
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
### 运行方式
|
||||
- Expo Go:`expo start` 扫码,视频播放用 WebView 方案
|
||||
- Dev Build:`expo run:android`,可使用更多 native 模块
|
||||
- Dev Build(推荐):`expo run:android`,支持 react-native-video(DASH/HLS)
|
||||
- Expo Go:`expo start` 扫码,react-native-video 可用但部分编解码受限,降级用 WebView 方案
|
||||
|
||||
### 常见问题
|
||||
- **视频无法播放**:检查 `fnval` 是否为 `0`,确认返回的是 MP4 而非 FLV
|
||||
- **视频无法播放**:检查 `fnval`;DASH 需确认 `buildDashMpdUri` 生成的 MPD 合法
|
||||
- **API 403**:`buvid3` Cookie 或 `SESSDATA` 失效,清除 AsyncStorage 重试
|
||||
- **评论为空**:B 站部分视频评论区关闭,`replies` 字段为 null 属正常
|
||||
- **二维码过期**:`pollQRCode` 返回 `code === 86038`,关闭 Modal 重新打开即可
|
||||
- **直播无法播放**:确认 HLS URL 有效;部分直播间仅提供 FLV,需选 FLV 流
|
||||
- **封面加载慢**:在设置中切换为「普通」清晰度可减小图片体积
|
||||
|
||||
### 扩展方向
|
||||
- 搜索功能:`/x/web-interface/search/all/v2`
|
||||
- 动态流:需更高权限 Cookie(`bili_jct` CSRF Token)
|
||||
- DASH 播放:需解析 MPD,配合支持 DASH 的播放器(如 `react-native-video` + dev build)
|
||||
- 弹幕:`/x/v1/dm/list.so?oid={cid}` 返回 XML 格式
|
||||
- 投稿/点赞/收藏:需登录态 + CSRF
|
||||
- 离线弹幕导出:DanmakuItem 已有完整数据结构
|
||||
|
||||
@@ -5,15 +5,18 @@ import { View } from 'react-native';
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useDownloadStore } from '../store/downloadStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { MiniPlayer } from '../components/MiniPlayer';
|
||||
|
||||
export default function RootLayout() {
|
||||
const restore = useAuthStore(s => s.restore);
|
||||
const loadDownloads = useDownloadStore(s => s.loadFromStorage);
|
||||
const restoreSettings = useSettingsStore(s => s.restore);
|
||||
|
||||
useEffect(() => {
|
||||
restore();
|
||||
loadDownloads();
|
||||
restoreSettings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -53,6 +56,14 @@ export default function RootLayout() {
|
||||
gestureDirection: "horizontal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
animation: "slide_from_right",
|
||||
gestureEnabled: true,
|
||||
gestureDirection: "horizontal",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<MiniPlayer />
|
||||
</View>
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function HomeScreen() {
|
||||
load: liveLoad,
|
||||
refresh: liveRefresh,
|
||||
} = useLiveList();
|
||||
const { isLoggedIn, face, logout } = useAuthStore();
|
||||
const { isLoggedIn, face } = useAuthStore();
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("hot");
|
||||
@@ -411,7 +411,7 @@ export default function HomeScreen() {
|
||||
<View style={styles.headerRight}>
|
||||
<TouchableOpacity
|
||||
style={styles.headerBtn}
|
||||
onPress={() => (isLoggedIn ? logout() : setShowLogin(true))}
|
||||
onPress={() => (isLoggedIn ? router.push('/settings' as any) : setShowLogin(true))}
|
||||
>
|
||||
{isLoggedIn && face ? (
|
||||
<Image source={{ uri: face }} style={styles.userAvatar} />
|
||||
|
||||
@@ -155,6 +155,7 @@ export default function LiveDetailScreen() {
|
||||
onToggle={() => {}}
|
||||
style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]}
|
||||
hideHeader
|
||||
isLive
|
||||
maxItems={500}
|
||||
giftCounts={giftCounts}
|
||||
/>
|
||||
|
||||
115
app/settings.tsx
Normal file
115
app/settings.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, logout } = useAuthStore();
|
||||
const { coverQuality, setCoverQuality } = useSettingsStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
|
||||
<Ionicons name="chevron-back" size={24} color="#212121" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.topTitle}>设置</Text>
|
||||
<View style={styles.spacer} />
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionLabel}>封面图清晰度</Text>
|
||||
<View style={styles.optionRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.option, coverQuality === 'hd' && styles.optionActive]}
|
||||
onPress={() => setCoverQuality('hd')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.optionText, coverQuality === 'hd' && styles.optionTextActive]}>
|
||||
高清
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.option, coverQuality === 'normal' && styles.optionActive]}
|
||||
onPress={() => setCoverQuality('normal')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.optionText, coverQuality === 'normal' && styles.optionTextActive]}>
|
||||
普通
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isLoggedIn && (
|
||||
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout} activeOpacity={0.8}>
|
||||
<Text style={styles.logoutText}>退出登录</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: '#f4f4f4' },
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
backBtn: { padding: 4, width: 32 },
|
||||
spacer: { width: 32 },
|
||||
topTitle: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#212121',
|
||||
textAlign: 'center',
|
||||
},
|
||||
section: {
|
||||
backgroundColor: '#fff',
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: '#eee',
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
sectionLabel: { fontSize: 13, color: '#999', marginBottom: 10 },
|
||||
optionRow: { flexDirection: 'row', gap: 10 },
|
||||
option: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
optionActive: { borderColor: '#00AEEC', backgroundColor: '#e8f7fd' },
|
||||
optionText: { fontSize: 14, color: '#666' },
|
||||
optionTextActive: { color: '#00AEEC', fontWeight: '600' },
|
||||
logoutBtn: {
|
||||
margin: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ff4757',
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoutText: { fontSize: 15, color: '#ff4757', fontWeight: '600' },
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
@@ -19,7 +18,8 @@ import { DanmakuItem } from "../../services/types";
|
||||
import DanmakuList from "../../components/DanmakuList";
|
||||
import { useVideoDetail } from "../../hooks/useVideoDetail";
|
||||
import { useComments } from "../../hooks/useComments";
|
||||
import { formatCount } from "../../utils/format";
|
||||
import { useRelatedVideos } from "../../hooks/useRelatedVideos";
|
||||
import { formatCount, formatDuration } from "../../utils/format";
|
||||
import { proxyImageUrl } from "../../utils/imageUrl";
|
||||
import { DownloadSheet } from "../../components/DownloadSheet";
|
||||
|
||||
@@ -47,6 +47,15 @@ export default function VideoDetailScreen() {
|
||||
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [showDownload, setShowDownload] = useState(false);
|
||||
const {
|
||||
videos: relatedVideos,
|
||||
loading: relatedLoading,
|
||||
load: loadRelated,
|
||||
} = useRelatedVideos();
|
||||
|
||||
useEffect(() => {
|
||||
loadRelated();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (video?.aid) loadComments();
|
||||
@@ -141,12 +150,19 @@ export default function VideoDetailScreen() {
|
||||
{videoLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : video ? (
|
||||
tab === "intro" ? (
|
||||
// 简介:视频信息 + 合集 + 简介文本
|
||||
<ScrollView
|
||||
<>
|
||||
{tab === "intro" && (
|
||||
<FlatList<import("../../services/types").VideoItem>
|
||||
style={styles.tabScroll}
|
||||
data={relatedVideos}
|
||||
keyExtractor={(item) => item.bvid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
onEndReached={() => {
|
||||
if (!relatedLoading) loadRelated();
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<View style={styles.upRow}>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(video.owner.face) }}
|
||||
@@ -170,22 +186,74 @@ export default function VideoDetailScreen() {
|
||||
<SeasonSection
|
||||
season={video.ugc_season}
|
||||
currentBvid={bvid as string}
|
||||
onEpisodePress={(epBvid) => router.replace(`/video/${epBvid}`)}
|
||||
onEpisodePress={(epBvid) =>
|
||||
router.replace(`/video/${epBvid}`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.descBox}>
|
||||
<Text style={styles.descText}>{video.desc || "暂无简介"}</Text>
|
||||
<Text style={styles.descText}>
|
||||
{video.desc || "暂无简介"}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : tab === "danmaku" ? (
|
||||
<DanmakuList
|
||||
danmakus={danmakus}
|
||||
currentTime={currentTime}
|
||||
visible={true}
|
||||
onToggle={() => {}}
|
||||
style={styles.danmakuTab}
|
||||
<View style={styles.relatedHeader}>
|
||||
<Text style={styles.relatedHeaderText}>推荐视频</Text>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.relatedCard}
|
||||
onPress={() => router.push(`/video/${item.bvid}` as any)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={styles.relatedThumbWrap}>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(item.pic) }}
|
||||
style={styles.relatedThumb}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.relatedDuration}>
|
||||
<Text style={styles.relatedDurationText}>
|
||||
{formatDuration(item.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.relatedInfo}>
|
||||
<Text style={styles.relatedTitle} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Text style={styles.relatedOwner} numberOfLines={1}>
|
||||
{item.owner?.name ?? ""}
|
||||
</Text>
|
||||
<Text style={styles.relatedView}>
|
||||
{formatCount(item.stat?.view ?? 0)} 播放
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
!relatedLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : null
|
||||
}
|
||||
ListFooterComponent={
|
||||
relatedLoading ? (
|
||||
<ActivityIndicator style={styles.loader} color="#00AEEC" />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "comments" && (
|
||||
<FlatList
|
||||
style={styles.tabScroll}
|
||||
data={comments}
|
||||
@@ -241,10 +309,26 @@ export default function VideoDetailScreen() {
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
!cmtLoading ? <Text style={styles.emptyTxt}>暂无评论</Text> : null
|
||||
!cmtLoading ? (
|
||||
<Text style={styles.emptyTxt}>暂无评论</Text>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 弹幕面板:始终挂载,切 tab 时用 display:none 隐藏而不卸载 */}
|
||||
<DanmakuList
|
||||
danmakus={danmakus}
|
||||
currentTime={currentTime}
|
||||
visible={tab === "danmaku"}
|
||||
onToggle={() => {}}
|
||||
hideHeader={true}
|
||||
style={[
|
||||
styles.danmakuTab,
|
||||
tab !== "danmaku" && { display: "none" },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -444,6 +528,55 @@ const styles = StyleSheet.create({
|
||||
descText: { fontSize: 14, color: "#555", lineHeight: 22 },
|
||||
danmakuTab: { flex: 1 },
|
||||
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
|
||||
relatedHeader: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: "#f0f0f0",
|
||||
backgroundColor: "#f4f4f4",
|
||||
},
|
||||
relatedHeaderText: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600" as const,
|
||||
color: "#212121",
|
||||
},
|
||||
relatedCard: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: "#fff",
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
gap: 10,
|
||||
},
|
||||
relatedThumbWrap: {
|
||||
position: "relative",
|
||||
width: 120,
|
||||
height: 68,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#eee",
|
||||
flexShrink: 0,
|
||||
},
|
||||
relatedThumb: { width: 120, height: 68 },
|
||||
relatedDuration: {
|
||||
position: "absolute",
|
||||
bottom: 3,
|
||||
right: 3,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
borderRadius: 3,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
relatedDurationText: { color: "#fff", fontSize: 10 },
|
||||
relatedInfo: {
|
||||
flex: 1,
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 2,
|
||||
},
|
||||
relatedTitle: { fontSize: 13, color: "#212121", lineHeight: 18 },
|
||||
relatedOwner: { fontSize: 12, color: "#999" },
|
||||
relatedView: { fontSize: 11, color: "#bbb" },
|
||||
sortRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -20,7 +20,8 @@ import Video, { VideoRef } 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 { coverImageUrl } from "../utils/imageUrl";
|
||||
import { useSettingsStore } from "../store/settingsStore";
|
||||
import { formatCount, formatDuration } from "../utils/format";
|
||||
import type { VideoItem } from "../services/types";
|
||||
|
||||
@@ -54,6 +55,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
onPress,
|
||||
}: Props) {
|
||||
const { width: SCREEN_W } = useWindowDimensions();
|
||||
const coverQuality = useSettingsStore(s => s.coverQuality);
|
||||
const THUMB_H = SCREEN_W * 0.5625;
|
||||
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
|
||||
|
||||
@@ -258,7 +260,7 @@ export const BigVideoCard = React.memo(function BigVideoCard({
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(item.pic) }}
|
||||
source={{ uri: coverImageUrl(item.pic, coverQuality) }}
|
||||
style={mediaDimensions}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
onToggle: () => void;
|
||||
style?: object | object[];
|
||||
hideHeader?: boolean;
|
||||
isLive?: boolean;
|
||||
maxItems?: number;
|
||||
giftCounts?: Record<string, number>;
|
||||
}
|
||||
@@ -60,6 +61,7 @@ export default function DanmakuList({
|
||||
onToggle,
|
||||
style,
|
||||
hideHeader,
|
||||
isLive,
|
||||
maxItems = 100,
|
||||
giftCounts,
|
||||
}: Props) {
|
||||
@@ -90,7 +92,7 @@ export default function DanmakuList({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hideHeader) {
|
||||
if (isLive) {
|
||||
const newStart = processedIndexRef.current;
|
||||
if (danmakus.length > newStart) {
|
||||
queueRef.current.push(...danmakus.slice(newStart));
|
||||
@@ -105,11 +107,11 @@ export default function DanmakuList({
|
||||
setDisplayedItems([]);
|
||||
setUnseenCount(0);
|
||||
isAtBottomRef.current = true;
|
||||
}, [danmakus, hideHeader]);
|
||||
}, [danmakus, isLive]);
|
||||
|
||||
// Watch currentTime — only used in video mode
|
||||
useEffect(() => {
|
||||
if (!visible || danmakus.length === 0 || hideHeader) return;
|
||||
if (danmakus.length === 0 || isLive) return;
|
||||
|
||||
const prevTime = lastTimeRef.current;
|
||||
lastTimeRef.current = currentTime;
|
||||
@@ -140,12 +142,10 @@ export default function DanmakuList({
|
||||
i++;
|
||||
}
|
||||
processedIndexRef.current = i;
|
||||
}, [currentTime, danmakus, visible, hideHeader]);
|
||||
}, [currentTime, danmakus, isLive]);
|
||||
|
||||
// Drip interval
|
||||
// Drip interval — always running so queue is consumed even when tab is hidden
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const id = setInterval(
|
||||
() => {
|
||||
if (queueRef.current.length === 0) return;
|
||||
@@ -185,7 +185,7 @@ export default function DanmakuList({
|
||||
);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
@@ -297,9 +297,9 @@ export default function DanmakuList({
|
||||
ref={flatListRef}
|
||||
data={displayedItems}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={hideHeader ? renderLiveItem : renderVideoItem}
|
||||
style={hideHeader ? liveStyles.list : styles.list}
|
||||
contentContainerStyle={hideHeader ? liveStyles.listContent : styles.listContent}
|
||||
renderItem={isLive ? renderLiveItem : renderVideoItem}
|
||||
style={isLive ? liveStyles.list : styles.list}
|
||||
contentContainerStyle={isLive ? liveStyles.listContent : styles.listContent}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
scrollEventThrottle={16}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { VideoItem } from "../services/types";
|
||||
import { formatCount, formatDuration } from "../utils/format";
|
||||
import { proxyImageUrl } from "../utils/imageUrl";
|
||||
import { coverImageUrl } from "../utils/imageUrl";
|
||||
import { useSettingsStore } from "../store/settingsStore";
|
||||
|
||||
const { width } = Dimensions.get("window");
|
||||
const CARD_WIDTH = (width - 14) / 2;
|
||||
@@ -21,6 +22,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) {
|
||||
const coverQuality = useSettingsStore(s => s.coverQuality);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
@@ -29,7 +31,7 @@ export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props)
|
||||
>
|
||||
<View style={styles.thumbContainer}>
|
||||
<Image
|
||||
source={{ uri: proxyImageUrl(item.pic) }}
|
||||
source={{ uri: coverImageUrl(item.pic, coverQuality) }}
|
||||
style={styles.thumb}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
29
hooks/useRelatedVideos.ts
Normal file
29
hooks/useRelatedVideos.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { getRecommendFeed } from '../services/bilibili';
|
||||
import type { VideoItem } from '../services/types';
|
||||
|
||||
export function useRelatedVideos() {
|
||||
const [pages, setPages] = useState<VideoItem[][]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
const freshIdxRef = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getRecommendFeed(freshIdxRef.current);
|
||||
setPages(prev => [...prev, data]);
|
||||
freshIdxRef.current += 1;
|
||||
} catch (e) {
|
||||
console.warn('useRelatedVideos: failed', e);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const videos = useMemo(() => pages.flat(), [pages]);
|
||||
return { videos, loading, load };
|
||||
}
|
||||
22
store/settingsStore.ts
Normal file
22
store/settingsStore.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
interface SettingsState {
|
||||
coverQuality: 'hd' | 'normal';
|
||||
setCoverQuality: (q: 'hd' | 'normal') => Promise<void>;
|
||||
restore: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
coverQuality: 'hd',
|
||||
|
||||
setCoverQuality: async (q) => {
|
||||
await AsyncStorage.setItem('COVER_QUALITY', q);
|
||||
set({ coverQuality: q });
|
||||
},
|
||||
|
||||
restore: async () => {
|
||||
const q = await AsyncStorage.getItem('COVER_QUALITY');
|
||||
if (q === 'hd' || q === 'normal') set({ coverQuality: q });
|
||||
},
|
||||
}));
|
||||
@@ -15,3 +15,11 @@ export function proxyImageUrl(url: string): string {
|
||||
// Native: force HTTPS so Release APK doesn't block cleartext HTTP
|
||||
return url.replace(/^http:\/\//, 'https://');
|
||||
}
|
||||
|
||||
export function coverImageUrl(url: string, quality: 'hd' | 'normal'): string {
|
||||
const base = proxyImageUrl(url);
|
||||
if (quality === 'normal' && base) {
|
||||
return base.replace(/(@[\w]+)?$/, '@320w_180h_1c.webp');
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user