设置页面

This commit is contained in:
Developer
2026-03-19 16:34:56 +08:00
parent 965a6b411f
commit 462a090599
12 changed files with 586 additions and 156 deletions

183
CLAUDE.md
View File

@@ -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-videoDASH/HLS/MP4|
| 视频播放(首页 BigCard | react-native-videoDASH MPD内联自动播放 |
| 视频播放WebView 降级) | react-native-webviewNativeVideoPlayer |
| 页面滑动 | react-native-pager-view首页热门/直播 Tab |
| 图标 | @expo/vector-iconsIonicons |
## 目录结构
```
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 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 # 热门视频分页加载(支持下拉刷新、上拉加载更多)
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 # proxyImageUrlWeb 代理绕防盗链)/ 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 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 关键点
@@ -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 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: 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 buildnative 编译),在 Expo Go 中报 `Cannot find native module 'ExponentAV'`
- `react-native-webview` 在 Expo Go 中开箱即用
-`JSON.stringify(uri)` 注入 src防止 URL 中 `&``=` 等特殊字符破坏 HTML 属性
### 首页 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`
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-videoDASH/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 已有完整数据结构

View File

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

View File

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

View File

@@ -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
View 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' },
});

View File

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

View File

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

View File

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

View File

@@ -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
View 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
View 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 });
},
}));

View File

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