This commit is contained in:
Developer
2026-03-26 12:15:40 +08:00
commit 3f82646496
108 changed files with 20886 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
给代码加注释 $FILE_NAME
1.每一行代码上方加上注释
2.注释内容需要简洁,使用中文

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "Expo Dev Server",
"runtimeExecutable": "node",
"runtimeArgs": ["C:\\nvm4w\\nodejs\\node_modules\\npm\\bin\\npm-cli.js", "run", "start"],
"port": 8081
}
]
}

1
.env Normal file
View File

@@ -0,0 +1 @@
EXPO_PUBLIC_SENTRY_DSN=https://bdb940f3a950ee46ce8ba651dee9b433@o4511094585819136.ingest.de.sentry.io/4511094601810000

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

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 使用问题 / 求助
url: https://github.com/tiajinsha/JKVideo/discussions
about: 使用上的疑问请到 Discussions 提问

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 规范
- [ ] 已在本地测试通过

View File

@@ -0,0 +1,85 @@
name: Auto Close Invalid Issues
on:
issues:
types: [opened]
jobs:
check-issue:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v7
env:
ABUSIVE_WORDS: ${{ secrets.ABUSIVE_WORDS }}
with:
script: |
const { owner, repo } = context.repo;
const issue_number = context.payload.issue.number;
const title = (context.payload.issue.title || '').toLowerCase();
const body = (context.payload.issue.body || '').toLowerCase();
const text = title + ' ' + body;
// --- 第一层:攻击性内容(词库存于 Secret: ABUSIVE_WORDS逗号分隔---
const abusiveWords = (process.env.ABUSIVE_WORDS || '').split(',').map(w => w.trim()).filter(Boolean);
const isAbusive = abusiveWords.some(w => w && text.includes(w.toLowerCase()));
if (isAbusive) {
await github.rest.issues.createComment({
owner, repo, issue_number,
body: [
'你好,',
'',
'此 Issue 包含不文明内容,已自动关闭。',
'',
'我们欢迎任何建设性的反馈,但请保持礼貌和尊重。',
'如有 Bug 或功能建议,请使用官方模板重新提交,谢谢。',
].join('\n'),
});
await github.rest.issues.update({
owner, repo, issue_number,
state: 'closed',
state_reason: 'not_planned',
});
await github.rest.issues.addLabels({
owner, repo, issue_number,
labels: ['invalid'],
});
return;
}
// --- 第二层:未走模板 ---
const templateMarkers = [
'### 运行平台',
'### 问题描述',
'### 复现步骤',
'### 需求背景',
'### 建议方案',
];
const isFromTemplate = templateMarkers.some(m => body.includes(m.toLowerCase()));
if (!isFromTemplate) {
await github.rest.issues.createComment({
owner, repo, issue_number,
body: [
'感谢你的反馈!',
'',
'此 Issue 未按照模板填写,已自动关闭。',
'',
'请使用以下方式重新提交:',
'- 🐛 **Bug 报告** → 使用 [Bug 报告模板](../../issues/new?template=bug_report.yml)',
'- 💡 **功能建议** → 使用 [功能建议模板](../../issues/new?template=feature_request.yml)',
'- 💬 **使用咨询** → 前往 [Discussions](../../discussions) 提问',
].join('\n'),
});
await github.rest.issues.update({
owner, repo, issue_number,
state: 'closed',
state_reason: 'not_planned',
});
await github.rest.issues.addLabels({
owner, repo, issue_number,
labels: ['invalid'],
});
}

74
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: Release APK
on:
push:
branches:
- master
jobs:
release:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Bump version
id: bump
run: |
NEW_VERSION=$(node scripts/bump-version.js)
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Commit version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add app.json package.json
git commit -m "chore: bump version to v${{ steps.bump.outputs.version }} [skip ci]"
git push
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Expo Prebuild
run: npx expo prebuild --platform android --no-install
- name: Grant Gradle execute permission
run: chmod +x android/gradlew
- name: Build Release APK
env:
SENTRY_DISABLE_AUTO_UPLOAD: 'true'
run: |
cd android
./gradlew assembleRelease --no-daemon
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.bump.outputs.version }}" \
--title "JKVideo v${{ steps.bump.outputs.version }}" \
--generate-notes \
android/app/build/outputs/apk/release/app-release.apk

54
.gitignore vendored Normal file
View File

@@ -0,0 +1,54 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
# git worktrees
.worktrees/
# Internal AI instructions & planning docs
CLAUDE.md
docs/
feature.md
livePlan.md
Promotion.md
wordsFilter.md
.env.local
reactJKVideoApp.zip

View File

@@ -0,0 +1,33 @@
---
inclusion: always
---
# 提交规范Conventional Commits
所有 git commit 必须遵循以下格式:
```
<type>(<scope>): <描述>
[可选正文]
```
## 类型说明
| 类型 | 含义 |
|------|------|
| feat | 新功能 |
| fix | Bug 修复 |
| refactor | 重构(不改变功能) |
| docs | 文档更新 |
| chore | 构建脚本、依赖更新等 |
| style | 代码格式(不影响逻辑) |
| perf | 性能优化 |
## 示例
```
feat(danmaku): 添加弹幕字体大小设置
fix(player): 修复 DASH MPD 解析在 Android 12 上崩溃的问题
docs: 更新 README 快速开始步骤
```

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5501
}

40
App.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
import * as Sentry from '@sentry/react-native';
Sentry.init({
dsn: 'https://bdb940f3a950ee46ce8ba651dee9b433@o4511094585819136.ingest.de.sentry.io/4511094601810000',
// Adds more context data to events (IP address, cookies, user, etc.)
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
sendDefaultPii: true,
// Enable Logs
enableLogs: true,
// Configure Session Replay
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1,
integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()],
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: __DEV__,
});
export default Sentry.wrap(function App() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

119
CHANGELOG.md Normal file
View File

@@ -0,0 +1,119 @@
# Changelog
所有重要更新都记录在此文件中。
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
---
## [1.0.15] - 2026-03-26
### 新增
- **UP主主页**:视频详情页点击 UP 主名称可进入创作者主页(`/creator/[mid]`),展示头像、简介、粉丝数、投稿数及视频列表(分页加载)
- **缓存管理**:设置页新增「存储」区块,实时显示缓存大小,支持一键清除 expo-image 磁盘/内存缓存及通用缓存目录
### 优化
- **性能**:封面图全面改用 `expo-image`(内存/磁盘两级缓存,`recyclingKey` 复用 UI 树节点FlatList 开启 `removeClippedSubviews``buvid3` 改为懒初始化,首屏加载快约 30ms
- **搜索体验**搜索结果列表高亮命中词HTML 标签过滤);空态增加图标+文案;防抖 300ms 减少无效请求
- **深色模式**:修补设置页遗漏的硬编码颜色,外观/流量切换按钮、退出登录按钮全部跟随主题
- **登录安全**SESSDATA 迁移至 `expo-secure-store` 加密存储Web 降级 AsyncStorage
### 修复
- **登录后头像不更新**:将 `getUserInfo()` 移入 `authStore.login()`,延迟 1s 后台执行,避免触发新会话并发风控,登录完成后头像立即刷新
- **getFollowedLiveRooms 登录后返回空**`login()` 内同步调用 `getUserInfo` 导致 `FollowedLiveStrip` 并发请求被平台风控拦截;改为 `setTimeout(1000)` 非阻塞后台拉取修复
- **getUploaderVideos 无数据**`/x/space/wbi/arc/search` 需要 WBI 签名,补全 `getWbiKeys()` + `signWbi()` 调用
- **getFollowedLiveRooms 字段兼容**:新增 `code !== 0` 校验及 `list ?? rooms ?? []` 兼容不同 API 版本返回结构
---
## [1.0.13] - 2026-03-25
### 修复
- **小窗 PanResponder 闭包过期**`useRef(PanResponder.create(...))` 捕获初始 `roomId=0` / `bvid=""`,导致点击小窗跳转到错误页面;改用 `storeRef` 模式保持最新值
- **直播小窗进入详情无限 loading**`useLiveDetail` 使用 `cancelled` 闭包标志effect cleanup 后 fetch 被静默丢弃;改用 `latestRoomId` ref 比对替代 cancelled 模式
- **进入播放器页面小窗不关闭**:视频/直播详情页进入时通过 `useLayoutEffect` + `getState().clearLive()` 同步清除小窗,避免双播和资源竞争
- **BigVideoCard 与直播小窗冲突**:首页 BigVideoCard 自动播放与直播小窗竞争解码器资源;小窗活跃时跳过 Video 渲染,仅显示封面图
- **退出全屏视频暂停**互斥渲染后竖屏播放器重新挂载react-native-video seek 后不自动恢复播放;`onLoad` 中强制 `paused` 状态切换触发播放
### 优化
- **视频播放器单实例**:竖屏/全屏互斥渲染(`{!fullscreen && ...}` / `{fullscreen && ...}`),不再同时挂载两个 Video 解码器,减半 GPU/内存占用
- **onProgress 节流**`progressUpdateInterval` 从 250ms 调为 500ms回调内增加 450ms 节流和 seeking 跳过,减少重渲染
- **移除调试日志**:清理 NativeVideoPlayer 中遗留的 `console.log`
- **下载页 UI 优化**:下载管理页交互和暗黑主题适配
---
## [1.0.12] - 2026-03-25
### 新增
- **UP主信息**:视频详情页博主名称下方展示粉丝数和视频数(`getUploaderStat``/x/web-interface/card`
- **视频相关推荐**:详情页推荐列表改为基于当前视频(`getVideoRelated``/x/web-interface/archive/related`),不再与首页 feed 共用
### 修复
- **直播全屏退出暂停**:全屏改用 `position:absolute` 覆盖Video 组件始终在同一棵 React 树中,不再因 Modal 切换导致重建暂停;退出全屏时直播自动暂停
- **直播画质选中**`changeQuality` 强制用请求的 `qn` 覆盖服务端协商值,画质面板高亮与用户选择一致
- **直播画质过滤**:过滤 `qn > 10000` 的选项(杜比/4K最高仅展示原画
- **推荐视频导航**:点击推荐列表改用 `router.replace`,避免详情页无限堆叠
### 优化
- **直播画质面板**:改为居中 Modal 弹出框
- **视频详情 Tab**:按钮向左靠齐,移除均分宽度
- **评论排序按钮**:统一为实心背景风格(`#f0f0f0``#00AEEC`),与直播分区 Tab 一致
- **设置页按钮**:外观/流量选项按钮统一为实心背景风格
## [1.0.11] - 2026-03-24
### 新增
- **暗黑模式**:全局主题系统(`utils/theme.ts`),支持亮色 / 暗色一键切换,覆盖所有页面和组件
- **节流模式**:设置页新增流量节省开关,开启后使用低画质封面、首页视频不自动播放、视频默认 360p 画质
- **本地二维码生成**:登录二维码改用 `react-native-qrcode-svg` 本地渲染,移除对 `api.qrserver.com` 的外部依赖,提升可靠性
### 修复
- **SeasonSection 背景色**:合集组件背景色与父容器不一致,现跟随主题色 (`theme.card`) 正确显示
- **推荐列表 Loading 状态**:空列表加载中未显示 spinner`ListEmptyComponent` 条件逻辑反转)
- **合集滚动定位偏移**`getItemLayout` offset 计算未计入卡片间距(`gap: 10`),导致 `scrollToIndex` 定位不准
- **推荐视频卡片双边框**:相邻推荐视频卡片之间出现双分割线
## [1.0.0] — 2026-03-20
### 首个正式版本
#### 视频播放
- DASH 完整播放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 原生播放)

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 等)
- **不接受**涉及自动化批量操作、绕过平台反爬的 PR
- 新增功能请优先开 Issue 讨论,避免重复劳动
- 涉及 API 参数变更时,请同步更新 `services/api.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.

183
README.en.md Normal file
View File

@@ -0,0 +1,183 @@
<div align="center">
<img src="https://img.shields.io/badge/JKVideo-Video_Client-00AEEC?style=for-the-badge&logoColor=white" alt="JKVideo"/>
# JKVideo
**A feature-rich React Native video 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**
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/tiajinsha/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/tiajinsha/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/ # Video platform 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 | API restriction |
| FLV live streams not supported | Neither HTML5 nor ExoPlayer support FLV; HLS auto-selected |
| Web requires local proxy | Image CDN 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 the respective platforms.
---
## License
[MIT](LICENSE) © 2026 JKVideo Contributors
---
<div align="center">
If this project helps you, please give it a ⭐ Star!
</div>

206
README.md Normal file
View File

@@ -0,0 +1,206 @@
<div align="center">
<img src="https://img.shields.io/badge/JKVideo-视频播放器客户端-00AEEC?style=for-the-badge&logoColor=white" alt="JKVideo"/>
# JKVideo
**高颜值的网络视频播放器 React Native 客户端**
*A feature-rich Video 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>直播详情 ·实时弹幕 · 清晰度切换</sub></td>
<td align="center"><img src="public/p6.jpg" width="180"/><br/><sub>视频弹幕 · 同步加载</sub></td>
</tr>
</table>
## 演示视频
https://github.com/tiajinsha/JKVideo/releases/download/v1.0.0/6490dcd9dba9a243a7cd8f00359cc285.mp4
---
## 功能亮点
🎬 **DASH 完整播放**
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/tiajinsha/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/tiajinsha/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/ # 视频平台 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
---
## 请作者喝杯咖啡 ☕
如果这个项目对你有所帮助,欢迎请作者喝杯咖啡,你的支持是持续开发的最大动力,感谢每一位愿意打赏的朋友!
<table>
<tr>
<td align="center">
<img src="public/wxpay.jpg" width="180"/><br/>
<sub>微信支付</sub>
</td>
<td align="center">
<img src="public/alipay.jpg" width="180"/><br/>
<sub>支付宝</sub>
</td>
</tr>
</table>
</div>

59
app.json Normal file
View File

@@ -0,0 +1,59 @@
{
"expo": {
"name": "JKVideo",
"slug": "jsvideo",
"version": "1.0.17",
"scheme": "jkvideo",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#00AEEC"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.REQUEST_INSTALL_PACKAGES",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.REQUEST_INSTALL_PACKAGES"
],
"package": "com.anonymous.jkvideo"
},
"web": {
"bundler": "metro",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"react-native-video",
"expo-screen-orientation",
"@sentry/react-native/expo",
"expo-secure-store",
"expo-image"
],
"experiments": {
"typedRoutes": true
},
"extra": {
"router": {},
"eas": {
"projectId": "eac4192a-ae80-461c-8fff-6e1a8b777bd1"
}
},
"owner": "jinsha"
}
}

105
app/_layout.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Text, View } from 'react-native';
import { useEffect } from 'react';
import { useAuthStore } from '../store/authStore';
import { useDownloadStore } from '../store/downloadStore';
import { useSettingsStore } from '../store/settingsStore';
import { useTheme } from '../utils/theme';
import { MiniPlayer } from '../components/MiniPlayer';
import { LiveMiniPlayer } from '../components/LiveMiniPlayer';
import * as Sentry from '@sentry/react-native';
import { ErrorBoundary } from '@sentry/react-native';
import { useFonts } from 'expo-font';
import { Ionicons } from '@expo/vector-icons';
Sentry.init({
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN ?? '',
enabled: !__DEV__,
tracesSampleRate: 0.05,
environment: process.env.EXPO_PUBLIC_APP_ENV ?? 'production',
});
function RootLayout() {
const restore = useAuthStore(s => s.restore);
const loadDownloads = useDownloadStore(s => s.loadFromStorage);
const restoreSettings = useSettingsStore(s => s.restore);
const darkMode = useSettingsStore(s => s.darkMode);
const [fontsLoaded] = useFonts({
...Ionicons.font,
});
useEffect(() => {
restore();
loadDownloads();
restoreSettings();
}, []);
if (!fontsLoaded) return null;
return (
<SafeAreaProvider>
<StatusBar style={darkMode ? 'light' : 'dark'} />
<View style={{ flex: 1 }}>
<ErrorBoundary fallback={<Text style={{ padding: 32, textAlign: 'center' }}> App</Text>}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen
name="video"
options={{
animation: "slide_from_right",
gestureEnabled: true,
gestureDirection: "horizontal",
}}
/>
<Stack.Screen
name="live"
options={{
animation: "slide_from_right",
gestureEnabled: true,
gestureDirection: "horizontal",
}}
/>
<Stack.Screen
name="search"
options={{
animation: "slide_from_right",
gestureEnabled: true,
}}
/>
<Stack.Screen
name="downloads"
options={{
animation: "slide_from_right",
gestureEnabled: true,
gestureDirection: "horizontal",
}}
/>
<Stack.Screen
name="settings"
options={{
animation: "slide_from_right",
gestureEnabled: true,
gestureDirection: "horizontal",
}}
/>
<Stack.Screen
name="creator"
options={{
animation: "slide_from_right",
gestureEnabled: true,
gestureDirection: "horizontal",
}}
/>
</Stack>
</ErrorBoundary>
<MiniPlayer />
<LiveMiniPlayer />
</View>
</SafeAreaProvider>
);
}
export default Sentry.wrap(RootLayout);

209
app/creator/[mid].tsx Normal file
View File

@@ -0,0 +1,209 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
View,
Text,
FlatList,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { getUploaderInfo, getUploaderVideos } from '../../services/api';
import type { VideoItem } from '../../services/types';
import { useTheme } from '../../utils/theme';
import { formatCount, formatDuration } from '../../utils/format';
import { proxyImageUrl, coverImageUrl } from '../../utils/imageUrl';
import { useSettingsStore } from '../../store/settingsStore';
const PAGE_SIZE = 20;
export default function CreatorScreen() {
const { mid: midStr } = useLocalSearchParams<{ mid: string }>();
const mid = Number(midStr);
const router = useRouter();
const theme = useTheme();
const trafficSaving = useSettingsStore(s => s.trafficSaving);
const [info, setInfo] = useState<{
name: string; face: string; sign: string; follower: number; archiveCount: number;
} | null>(null);
const [videos, setVideos] = useState<VideoItem[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [infoLoading, setInfoLoading] = useState(true);
const loadingRef = React.useRef(false);
useEffect(() => {
getUploaderInfo(mid)
.then(setInfo)
.catch(() => {})
.finally(() => setInfoLoading(false));
loadVideos(1, true);
}, [mid]);
const loadVideos = useCallback(async (pn: number, reset = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const { videos: newVideos, total: t } = await getUploaderVideos(mid, pn, PAGE_SIZE);
setTotal(t);
setVideos(prev => reset ? newVideos : [...prev, ...newVideos]);
setPage(pn);
} catch {}
finally {
loadingRef.current = false;
setLoading(false);
}
}, [mid]);
const hasMore = videos.length < total;
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'left', 'right']}>
{/* Top bar */}
<View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
{info?.name ?? 'UP主主页'}
</Text>
<View style={styles.backBtn} />
</View>
<FlatList
data={videos}
keyExtractor={item => item.bvid}
showsVerticalScrollIndicator={false}
onEndReached={() => { if (hasMore && !loading) loadVideos(page + 1); }}
onEndReachedThreshold={0.3}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews
ListHeaderComponent={
infoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : info ? (
<View style={[styles.profileCard, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<Image
source={{ uri: proxyImageUrl(info.face) }}
style={styles.avatar}
contentFit="cover"
recyclingKey={String(mid)}
/>
<Text style={[styles.name, { color: theme.text }]}>{info.name}</Text>
{info.sign ? (
<Text style={[styles.sign, { color: theme.textSub }]} numberOfLines={2}>{info.sign}</Text>
) : null}
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.follower)}</Text>
<Text style={[styles.statLabel, { color: theme.textSub }]}></Text>
</View>
<View style={[styles.statDivider, { backgroundColor: theme.border }]} />
<View style={styles.statItem}>
<Text style={[styles.statNum, { color: theme.text }]}>{formatCount(info.archiveCount)}</Text>
<Text style={[styles.statLabel, { color: theme.textSub }]}></Text>
</View>
</View>
<Text style={[styles.videoListHeader, { color: theme.textSub }]}>
{total}
</Text>
</View>
) : null
}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.videoRow, { backgroundColor: theme.card, borderBottomColor: theme.border }]}
onPress={() => router.push(`/video/${item.bvid}` as any)}
activeOpacity={0.85}
>
<View style={styles.thumbWrap}>
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={styles.thumb}
contentFit="cover"
recyclingKey={item.bvid}
transition={200}
/>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>{formatDuration(item.duration)}</Text>
</View>
</View>
<View style={styles.videoInfo}>
<Text style={[styles.videoTitle, { color: theme.text }]} numberOfLines={2}>
{item.title}
</Text>
<View style={styles.videoMeta}>
<Ionicons name="play" size={11} color={theme.textSub} />
<Text style={[styles.metaText, { color: theme.textSub }]}>{formatCount(item.stat.view)}</Text>
</View>
</View>
</TouchableOpacity>
)}
ListEmptyComponent={
!loading && !infoLoading ? (
<Text style={[styles.emptyTxt, { color: theme.textSub }]}></Text>
) : null
}
ListFooterComponent={
loading ? <ActivityIndicator style={styles.loader} color="#00AEEC" /> : null
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
topBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4, width: 32 },
topTitle: { flex: 1, fontSize: 16, fontWeight: '600', textAlign: 'center' },
profileCard: {
alignItems: 'center',
paddingTop: 24,
paddingBottom: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
marginBottom: 4,
},
avatar: { width: 72, height: 72, borderRadius: 36, marginBottom: 10 },
name: { fontSize: 18, fontWeight: '700', marginBottom: 6 },
sign: { fontSize: 13, textAlign: 'center', paddingHorizontal: 24, marginBottom: 12, lineHeight: 19 },
statsRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 },
statItem: { alignItems: 'center', paddingHorizontal: 24 },
statNum: { fontSize: 18, fontWeight: '700' },
statLabel: { fontSize: 12, marginTop: 2 },
statDivider: { width: 1, height: 28 },
videoListHeader: { alignSelf: 'flex-start', paddingHorizontal: 14, fontSize: 13, paddingBottom: 8 },
loader: { marginVertical: 24 },
videoRow: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 10,
},
thumbWrap: { width: 120, height: 68, borderRadius: 4, overflow: 'hidden', flexShrink: 0, position: 'relative' },
thumb: { width: 120, height: 68 },
durationBadge: {
position: 'absolute', bottom: 3, right: 3,
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 3, paddingHorizontal: 4, paddingVertical: 1,
},
durationText: { color: '#fff', fontSize: 10 },
videoInfo: { flex: 1, justifyContent: 'space-between', paddingVertical: 2 },
videoTitle: { fontSize: 13, lineHeight: 18 },
videoMeta: { flexDirection: 'row', alignItems: 'center', gap: 3 },
metaText: { fontSize: 12 },
emptyTxt: { textAlign: 'center', padding: 40 },
});

5
app/creator/_layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function CreatorLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

299
app/downloads.tsx Normal file
View File

@@ -0,0 +1,299 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
SectionList,
StyleSheet,
TouchableOpacity,
Image,
Modal,
StatusBar,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import Video from 'react-native-video';
let ScreenOrientation: typeof import('expo-screen-orientation') | null = null;
try { ScreenOrientation = require('expo-screen-orientation'); } catch {}
import { useDownloadStore, DownloadTask } from '../store/downloadStore';
import { LanShareModal } from '../components/LanShareModal';
import { proxyImageUrl } from '../utils/imageUrl';
import { useTheme } from '../utils/theme';
function formatFileSize(bytes?: number): string {
if (!bytes || bytes <= 0) return '';
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export default function DownloadsScreen() {
const router = useRouter();
const theme = useTheme();
const { tasks, loadFromStorage, removeTask } = useDownloadStore();
const [playingUri, setPlayingUri] = useState<string | null>(null);
const [playingTitle, setPlayingTitle] = useState('');
const [shareTask, setShareTask] = useState<(DownloadTask & { key: string }) | null>(null);
async function openPlayer(uri: string, title: string) {
setPlayingTitle(title);
setPlayingUri(uri);
await ScreenOrientation?.unlockAsync();
}
async function closePlayer() {
setPlayingUri(null);
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
}
function confirmDelete(key: string, status: DownloadTask['status']) {
const isDownloading = status === 'downloading';
Alert.alert(
isDownloading ? '取消下载' : '删除下载',
isDownloading ? '确定取消该下载任务?' : '确定删除该文件?删除后不可恢复。',
[
{ text: '取消', style: 'cancel' },
{ text: isDownloading ? '取消下载' : '删除', style: 'destructive', onPress: () => removeTask(key) },
],
);
}
useEffect(() => {
loadFromStorage();
}, []);
const all = Object.entries(tasks).map(([key, task]) => ({ key, ...task }));
const downloading = all.filter((t) => t.status === 'downloading' || t.status === 'error');
const done = all.filter((t) => t.status === 'done');
const sections = [];
if (downloading.length > 0) sections.push({ title: '下载中', data: downloading });
if (done.length > 0) sections.push({ title: '已下载', data: done });
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
<View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<Text style={[styles.topTitle, { color: theme.text }]}></Text>
<View style={{ width: 32 }} />
</View>
{sections.length === 0 ? (
<View style={styles.empty}>
<Ionicons name="cloud-download-outline" size={56} color={theme.textSub} />
<Text style={[styles.emptyTxt, { color: theme.textSub }]}></Text>
</View>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.key}
renderSectionHeader={({ section }) => (
<View style={[styles.sectionHeader, { backgroundColor: theme.bg }]}>
<Text style={[styles.sectionTitle, { color: theme.textSub }]}>{section.title}</Text>
</View>
)}
renderItem={({ item }) => (
<DownloadRow
task={item}
theme={theme}
onPlay={() => {
if (item.localUri) openPlayer(item.localUri, item.title);
}}
onDelete={() => confirmDelete(item.key, item.status)}
onShare={() => setShareTask(item)}
onRetry={() => router.push(`/video/${item.bvid}` as any)}
/>
)}
ItemSeparatorComponent={() => (
<View style={[styles.separator, { backgroundColor: theme.border, marginLeft: 108 }]} />
)}
contentContainerStyle={{ paddingBottom: 32 }}
/>
)}
<LanShareModal
visible={!!shareTask}
task={shareTask}
onClose={() => setShareTask(null)}
/>
{/* Local file player modal */}
<Modal
visible={!!playingUri}
animationType="fade"
statusBarTranslucent
onRequestClose={closePlayer}
>
<StatusBar hidden />
<View style={styles.playerBg}>
{playingUri && (
<Video
source={{ uri: playingUri }}
style={StyleSheet.absoluteFillObject}
resizeMode="contain"
controls
paused={false}
/>
)}
<View style={styles.playerBar}>
<TouchableOpacity onPress={closePlayer} style={styles.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Ionicons name="chevron-back" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.playerTitle} numberOfLines={1}>{playingTitle}</Text>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
function DownloadRow({
task,
theme,
onPlay,
onDelete,
onShare,
onRetry,
}: {
task: DownloadTask & { key: string };
theme: ReturnType<typeof useTheme>;
onPlay: () => void;
onDelete: () => void;
onShare: () => void;
onRetry: () => void;
}) {
const isDone = task.status === 'done';
const isError = task.status === 'error';
const isDownloading = task.status === 'downloading';
const rowContent = (
<View style={[styles.row, { backgroundColor: theme.card }]}>
<Image source={{ uri: proxyImageUrl(task.cover) }} style={styles.cover} />
<View style={styles.info}>
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>{task.title}</Text>
<Text style={[styles.qdesc, { color: theme.textSub }]}>
{task.qdesc}{task.fileSize ? ` · ${formatFileSize(task.fileSize)}` : ''}
</Text>
{isDownloading && (
<View style={styles.progressWrap}>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${Math.round(task.progress * 100)}%` as any }]} />
</View>
<Text style={styles.progressTxt}>{Math.round(task.progress * 100)}%</Text>
</View>
)}
{isError && (
<View style={styles.errorRow}>
<Text style={styles.errorTxt} numberOfLines={1}>{task.error ?? '下载失败'}</Text>
<TouchableOpacity onPress={onRetry} style={styles.retryBtn}>
<Text style={styles.retryTxt}></Text>
</TouchableOpacity>
</View>
)}
</View>
<View style={styles.actions}>
{isDone && (
<TouchableOpacity style={styles.actionBtn} onPress={onShare}>
<Ionicons name="share-social-outline" size={20} color="#00AEEC" />
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.actionBtn}
onPress={onDelete}
>
<Ionicons
name={isDownloading ? 'close-circle-outline' : 'trash-outline'}
size={20}
color="#bbb"
/>
</TouchableOpacity>
</View>
</View>
);
if (isDone) {
return (
<TouchableOpacity activeOpacity={0.85} onPress={onPlay}>
{rowContent}
</TouchableOpacity>
);
}
return rowContent;
}
const styles = StyleSheet.create({
safe: { flex: 1 },
topBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4 },
topTitle: {
flex: 1,
fontSize: 16,
fontWeight: '700',
marginLeft: 4,
},
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
emptyTxt: { fontSize: 14 },
sectionHeader: {
paddingHorizontal: 16,
paddingVertical: 8,
},
sectionTitle: { fontSize: 13, fontWeight: '600' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
gap: 12,
},
cover: { width: 80, height: 54, borderRadius: 6, backgroundColor: '#eee', flexShrink: 0 },
info: { flex: 1 },
title: { fontSize: 13, lineHeight: 18, marginBottom: 4 },
qdesc: { fontSize: 12, marginBottom: 4 },
progressWrap: { flexDirection: 'row', alignItems: 'center', marginTop: 2, gap: 6 },
progressTrack: {
flex: 1,
height: 3,
borderRadius: 2,
backgroundColor: '#e0e0e0',
overflow: 'hidden',
},
progressFill: { height: 3, backgroundColor: '#00AEEC', borderRadius: 2 },
progressTxt: { fontSize: 11, color: '#999', minWidth: 30 },
errorRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 2 },
errorTxt: { fontSize: 12, color: '#f44', flex: 1 },
retryBtn: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
backgroundColor: '#e8f7fd',
},
retryTxt: { fontSize: 12, color: '#00AEEC', fontWeight: '600' },
actions: { alignItems: 'center', gap: 12 },
actionBtn: { padding: 4 },
separator: { height: StyleSheet.hairlineWidth },
// player modal
playerBg: { flex: 1, backgroundColor: '#000', justifyContent: 'center' },
playerBar: {
position: 'absolute',
top: 44,
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
backgroundColor: 'rgba(0,0,0,0.4)',
paddingVertical: 8,
},
closeBtn: { padding: 6 },
playerTitle: { flex: 1, color: '#fff', fontSize: 14, fontWeight: '600', marginLeft: 4 },
});

620
app/index.tsx Normal file
View File

@@ -0,0 +1,620 @@
import React, {
useEffect,
useState,
useRef,
useMemo,
useCallback,
} from "react";
import {
View,
StyleSheet,
Text,
TouchableOpacity,
ActivityIndicator,
Animated,
Image,
RefreshControl,
ViewToken,
FlatList,
ScrollView,
} from "react-native";
import PagerView from "react-native-pager-view";
import {
SafeAreaView,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { VideoCard } from "../components/VideoCard";
import { LiveCard } from "../components/LiveCard";
import { LoginModal } from "../components/LoginModal";
import { DownloadProgressBtn } from "../components/DownloadProgressBtn";
import { useVideoList } from "../hooks/useVideoList";
import { useLiveList } from "../hooks/useLiveList";
import { useAuthStore } from "../store/authStore";
import {
toListRows,
type ListRow,
type BigRow,
type LiveRow,
} from "../utils/videoRows";
import { BigVideoCard } from "../components/BigVideoCard";
import { FollowedLiveStrip } from "../components/FollowedLiveStrip";
import { useTheme } from "../utils/theme";
import type { LiveRoom } from "../services/types";
const HEADER_H = 44;
const TAB_H = 38;
const NAV_H = HEADER_H + TAB_H;
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 50 };
type TabKey = "hot" | "live";
const TABS: { key: TabKey; label: string }[] = [
{ key: "hot", label: "热门" },
{ key: "live", label: "直播" },
];
const LIVE_AREAS = [
{ id: 0, name: "推荐" },
{ id: 2, name: "网游" },
{ id: 3, name: "手游" },
{ id: 6, name: "单机游戏" },
{ id: 1, name: "娱乐" },
{ id: 9, name: "虚拟主播" },
{ id: 10, name: "生活" },
{ id: 11, name: "知识" },
];
export default function HomeScreen() {
const router = useRouter();
const { pages, liveRooms, loading, refreshing, load, refresh } =
useVideoList();
const {
rooms,
loading: liveLoading,
refreshing: liveRefreshing,
load: liveLoad,
refresh: liveRefresh,
} = useLiveList();
const { isLoggedIn, face } = useAuthStore();
const [showLogin, setShowLogin] = useState(false);
const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState<TabKey>("hot");
const [liveAreaId, setLiveAreaId] = useState(0);
const theme = useTheme();
const [visibleBigKey, setVisibleBigKey] = useState<string | null>(null);
const rows = useMemo(() => toListRows(pages, liveRooms), [pages, liveRooms]);
const pagerRef = useRef<PagerView>(null);
const hotListRef = useRef<FlatList>(null);
const liveListRef = useRef<FlatList>(null);
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;
const scrollY = useRef(new Animated.Value(0)).current;
const headerTranslate = scrollY.interpolate({
inputRange: [0, HEADER_H],
outputRange: [0, -HEADER_H],
extrapolate: "clamp",
});
const headerOpacity = scrollY.interpolate({
inputRange: [0, HEADER_H * 0.2],
outputRange: [1, 0],
extrapolate: "clamp",
});
// 直播列表也共用同一个 scrollY
const liveScrollY = useRef(new Animated.Value(0)).current;
const liveHeaderTranslate = liveScrollY.interpolate({
inputRange: [0, HEADER_H],
outputRange: [0, -HEADER_H],
extrapolate: "clamp",
});
const liveHeaderOpacity = liveScrollY.interpolate({
inputRange: [0, HEADER_H * 0.2],
outputRange: [1, 0],
extrapolate: "clamp",
});
useEffect(() => {
load();
}, []);
const onScroll = useMemo(
() =>
Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
useNativeDriver: true,
}),
[],
);
const onLiveScroll = useMemo(
() =>
Animated.event([{ nativeEvent: { contentOffset: { y: liveScrollY } } }], {
useNativeDriver: true,
}),
[],
);
const handleTabPress = useCallback(
(key: TabKey) => {
if (key === activeTab) {
// 点击已激活的 tab滚动到顶部并刷新
if (key === "hot") {
hotListRef.current?.scrollToOffset({ offset: 0, animated: true });
refresh();
} else {
liveListRef.current?.scrollToOffset({ offset: 0, animated: true });
liveRefresh(liveAreaId);
}
return;
}
// 切换 tab
pagerRef.current?.setPage(key === "hot" ? 0 : 1);
setActiveTab(key);
if (key === "live" && rooms.length === 0) {
liveLoad(true, liveAreaId);
}
},
[activeTab, rooms.length, liveAreaId],
);
const onPageSelected = useCallback(
(e: any) => {
const key: TabKey = e.nativeEvent.position === 0 ? "hot" : "live";
if (key === activeTab) return;
setActiveTab(key);
if (key === "live" && rooms.length === 0) {
liveLoad(true, liveAreaId);
}
},
[activeTab, rooms.length, liveAreaId],
);
const handleLiveAreaPress = useCallback(
(areaId: number) => {
if (areaId === liveAreaId) return;
setLiveAreaId(areaId);
liveListRef.current?.scrollToOffset({ offset: 0, animated: false });
liveLoad(true, areaId);
},
[liveAreaId, liveLoad],
);
const visibleBigKeyRef = useRef(visibleBigKey);
visibleBigKeyRef.current = visibleBigKey;
const renderItem = useCallback(({ item: row }: { item: ListRow }) => {
if (row.type === "big") {
return (
<BigVideoCard
item={row.item}
isVisible={visibleBigKeyRef.current === row.item.bvid}
onPress={() => router.push(`/video/${row.item.bvid}` as any)}
/>
);
}
if (row.type === "live") {
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<LiveCard
isLivePulse
item={row.left}
onPress={() => router.push(`/live/${row.left.roomid}` as any)}
/>
</View>
{row.right && (
<View style={styles.rightCol}>
<LiveCard
isLivePulse
item={row.right}
onPress={() => router.push(`/live/${row.right!.roomid}` as any)}
/>
</View>
)}
</View>
);
}
const right = row.right;
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<VideoCard
item={row.left}
onPress={() => router.push(`/video/${row.left.bvid}` as any)}
/>
</View>
{right && (
<View style={styles.rightCol}>
<VideoCard
item={right}
onPress={() => router.push(`/video/${right.bvid}` as any)}
/>
</View>
)}
</View>
);
}, []);
const renderLiveItem = useCallback(
({ item }: { item: { left: LiveRoom; right?: LiveRoom } }) => (
<View style={styles.row}>
<View style={styles.leftCol}>
<LiveCard
item={item.left}
onPress={() => router.push(`/live/${item.left.roomid}` as any)}
/>
</View>
{item.right && (
<View style={styles.rightCol}>
<LiveCard
item={item.right}
onPress={() => router.push(`/live/${item.right!.roomid}` as any)}
/>
</View>
)}
</View>
),
[],
);
// 将直播列表分成两列的行
const liveRows = useMemo(() => {
const result: { left: LiveRoom; right?: LiveRoom }[] = [];
for (let i = 0; i < rooms.length; i += 2) {
result.push({ left: rooms[i], right: rooms[i + 1] });
}
return result;
}, [rooms]);
const currentHeaderTranslate =
activeTab === "hot" ? headerTranslate : liveHeaderTranslate;
const currentHeaderOpacity =
activeTab === "hot" ? headerOpacity : liveHeaderOpacity;
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={["left", "right"]}>
{/* 滑动切换容器 */}
<PagerView
ref={pagerRef}
style={styles.pager}
initialPage={0}
scrollEnabled={false}
onPageSelected={onPageSelected}
>
{/* 热门列表 */}
<View key="hot" collapsable={false}>
<Animated.FlatList
ref={hotListRef as any}
style={styles.listContainer}
data={rows}
keyExtractor={(row: any, index: number) =>
row.type === "big"
? `big-${row.item.bvid}`
: row.type === "live"
? `live-${index}-${row.left.roomid}-${row.right?.roomid ?? "empty"}`
: `pair-${row.left.bvid}-${row.right?.bvid ?? "empty"}`
}
contentContainerStyle={{
paddingTop: insets.top + NAV_H + 6,
paddingBottom: insets.bottom + 16,
}}
renderItem={renderItem}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={refresh}
progressViewOffset={insets.top + NAV_H}
/>
}
onEndReached={() => load()}
onEndReachedThreshold={0.5}
extraData={visibleBigKey}
viewabilityConfig={VIEWABILITY_CONFIG}
onViewableItemsChanged={onViewableItemsChangedRef}
ListFooterComponent={
<View style={styles.footer}>
{loading && <ActivityIndicator color="#00AEEC" />}
</View>
}
onScroll={onScroll}
scrollEventThrottle={16}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews={true}
/>
</View>
{/* 直播列表 */}
<View key="live" collapsable={false}>
<Animated.FlatList
ref={liveListRef as any}
style={styles.listContainer}
data={liveRows}
keyExtractor={(item: any, index: number) =>
`live-${index}-${item.left.roomid}-${item.right?.roomid ?? "empty"}`
}
contentContainerStyle={{
paddingTop: insets.top + NAV_H + 6,
paddingBottom: insets.bottom + 16,
}}
renderItem={renderLiveItem}
ListHeaderComponent={
<View>
<FollowedLiveStrip />
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.areaTabRow}
contentContainerStyle={styles.areaTabContent}
>
{LIVE_AREAS.map((area) => (
<TouchableOpacity
key={area.id}
style={[
styles.areaTab,
liveAreaId === area.id && styles.areaTabActive,
]}
onPress={() => handleLiveAreaPress(area.id)}
activeOpacity={0.7}
>
<Text
style={[
styles.areaTabText,
liveAreaId === area.id && styles.areaTabTextActive,
]}
>
{area.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
}
refreshControl={
<RefreshControl
refreshing={liveRefreshing}
onRefresh={() => liveRefresh(liveAreaId)}
progressViewOffset={insets.top + NAV_H}
/>
}
onEndReached={() => liveLoad()}
onEndReachedThreshold={1.5}
ListFooterComponent={
liveLoading ? (
<View style={styles.footer}>
<ActivityIndicator color="#00AEEC" />
<Text style={styles.footerText}>...</Text>
</View>
) : null
}
onScroll={onLiveScroll}
scrollEventThrottle={16}
windowSize={7}
maxToRenderPerBatch={6}
removeClippedSubviews={true}
/>
</View>
</PagerView>
{/* 绝对定位导航栏 */}
<Animated.View
style={[
styles.navBar,
{
paddingTop: insets.top,
backgroundColor: theme.card,
transform: [{ translateY: currentHeaderTranslate }],
},
]}
>
<Animated.View
style={[
styles.header,
{
opacity: currentHeaderOpacity,
},
]}
>
<View style={styles.headerRight}>
<TouchableOpacity
style={styles.headerBtn}
onPress={() => (isLoggedIn ? router.push('/settings' as any) : setShowLogin(true))}
>
{isLoggedIn && face ? (
<Image source={{ uri: face }} style={styles.userAvatar} />
) : (
<Ionicons
name={isLoggedIn ? "person" : "person-outline"}
size={22}
color="#00AEEC"
/>
)}
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.searchBar, { backgroundColor: theme.inputBg }]}
onPress={() => router.push("/search" as any)}
activeOpacity={0.7}
>
<Ionicons name="search" size={14} color={theme.textSub} />
<Text style={[styles.searchPlaceholder, { color: theme.textSub }]}>UP主...</Text>
</TouchableOpacity>
<DownloadProgressBtn
onPress={() => router.push("/downloads" as any)}
/>
</Animated.View>
<View style={[styles.tabRow, { backgroundColor: theme.card }]}>
{TABS.map((tab) => (
<TouchableOpacity
key={tab.key}
style={styles.tabItem}
onPress={() => handleTabPress(tab.key)}
activeOpacity={0.7}
>
<Text
style={[
styles.tabText,
{ color: theme.textSub },
activeTab === tab.key && styles.tabTextActive,
]}
>
{tab.label}
</Text>
{activeTab === tab.key && <View style={styles.tabUnderline} />}
</TouchableOpacity>
))}
</View>
</Animated.View>
<LoginModal visible={showLogin} onClose={() => setShowLogin(false)} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: "#f4f4f4" },
pager: { flex: 1 },
listContainer: { flex: 1 },
navBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 10,
backgroundColor: "#fff",
overflow: "hidden",
elevation: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
},
header: {
height: HEADER_H,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
gap: 10,
},
logo: {
fontSize: 20,
fontWeight: "800",
color: "#00AEEC",
letterSpacing: -0.5,
width: 72,
},
searchBar: {
flex: 1,
height: 30,
marginLeft: 8,
backgroundColor: "#f0f0f0",
borderRadius: 15,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
gap: 5,
},
downloadBtn: {},
searchPlaceholder: {
fontSize: 13,
color: "#999",
flex: 1,
},
headerRight: { flexDirection: "row", gap: 8, alignItems: "center" },
headerBtn: { paddingLeft: 0 },
userAvatar: {
width: 35,
height: 35,
borderRadius: 50,
backgroundColor: "#eee",
},
tabRow: {
height: TAB_H,
backgroundColor: "#fff",
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 20,
},
tabItem: {
alignItems: "center",
justifyContent: "center",
height: TAB_H,
},
tabText: {
fontSize: 15,
fontWeight: "450",
color: "#999",
},
tabTextActive: {
fontWeight: "500",
color: "#00AEEC",
},
tabUnderline: {
position: "absolute",
bottom: 4,
width: 24,
height: 3,
backgroundColor: "#00AEEC",
borderRadius: 4,
},
row: {
flexDirection: "row",
paddingHorizontal: 1,
justifyContent: "flex-start",
},
leftCol: { marginLeft: 4, marginRight: 2 },
rightCol: { marginLeft: 2, marginRight: 4 },
footer: {
height: 48,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 6,
},
footerText: { fontSize: 12, color: "#999" },
areaTabRow: {
marginBottom: 6,
},
areaTabContent: {
paddingHorizontal: 8,
gap: 8,
alignItems: "center",
height: 36,
},
areaTab: {
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 16,
backgroundColor: "#f0f0f0",
},
areaTabActive: {
backgroundColor: "#00AEEC",
},
areaTabText: {
fontSize: 13,
color: "#333",
fontWeight: "500",
},
areaTabTextActive: {
color: "#fff",
fontWeight: "600",
},
});

292
app/live/[roomId].tsx Normal file
View File

@@ -0,0 +1,292 @@
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
Image,
ActivityIndicator,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useLiveDetail } from "../../hooks/useLiveDetail";
import { useLiveDanmaku } from "../../hooks/useLiveDanmaku";
import { LivePlayer } from "../../components/LivePlayer";
import DanmakuList from "../../components/DanmakuList";
import { formatCount } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl";
import { useTheme } from "../../utils/theme";
import { useLiveStore } from "../../store/liveStore";
type Tab = "intro" | "danmaku";
export default function LiveDetailScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>();
const router = useRouter();
const theme = useTheme();
const id = parseInt(roomId ?? "0", 10);
// 进入详情页时立即清除小窗useLayoutEffect 在绘制前同步执行)
useLayoutEffect(() => {
useLiveStore.getState().clearLive();
}, []);
const { room, anchor, stream, loading, error, changeQuality } =
useLiveDetail(id);
const [tab, setTab] = useState<Tab>("intro");
const isLive = room?.live_status === 1;
const hlsUrl = stream?.hlsUrl ?? "";
const flvUrl = stream?.flvUrl ?? "";
const qualities = stream?.qualities ?? [];
const currentQn = stream?.qn ?? 0;
const setLive = useLiveStore(s => s.setLive);
const actualRoomId = room?.roomid ?? id;
const { danmakus, giftCounts } = useLiveDanmaku(isLive ? actualRoomId : 0);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
{/* TopBar */}
<View style={[styles.topBar, { borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<Text style={[styles.topTitle, { color: theme.text }]} numberOfLines={1}>
{room?.title ?? "直播间"}
</Text>
{isLive && hlsUrl ? (
<TouchableOpacity
style={styles.pipBtn}
onPress={() => {
setLive(id, room?.title ?? '', room?.keyframe ?? '', hlsUrl);
router.back();
}}
>
<Ionicons name="browsers-outline" size={22} color={theme.text} />
</TouchableOpacity>
) : (
<View style={styles.pipBtn} />
)}
</View>
{/* Player */}
<LivePlayer
hlsUrl={hlsUrl}
flvUrl={flvUrl}
isLive={isLive}
qualities={qualities}
currentQn={currentQn}
onQualityChange={changeQuality}
/>
{/* TabBar */}
<View style={[styles.tabBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("intro")}
>
<Text style={[styles.tabLabel, { color: theme.textSub }, tab === "intro" && styles.tabActive]}>
</Text>
{tab === "intro" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("danmaku")}
>
<Text
style={[styles.tabLabel, { color: theme.textSub }, tab === "danmaku" && styles.tabActive]}
>
{danmakus.length > 0 ? ` ${danmakus.length}` : ""}
</Text>
{tab === "danmaku" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
</View>
{/* Content */}
{loading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : error ? (
<Text style={styles.errorText}>{error}</Text>
) : (
<>
<ScrollView
style={[styles.scroll, tab !== "intro" && styles.hidden]}
showsVerticalScrollIndicator={false}
>
<View style={styles.titleSection}>
<Text style={[styles.title, { color: theme.text }]}>{room?.title}</Text>
<View style={styles.metaRow}>
{isLive ? (
<View style={styles.livePill}>
<View style={styles.liveDot} />
<Text style={styles.livePillText}></Text>
</View>
) : (
<View style={[styles.livePill, styles.offlinePill]}>
<Text style={[styles.livePillText, styles.offlinePillText]}>
</Text>
</View>
)}
<View style={styles.onlineRow}>
<Ionicons name="eye-outline" size={13} color="#999" />
<Text style={styles.onlineText}>
{formatCount(room?.online ?? 0)}
</Text>
</View>
</View>
<View style={styles.areaRow}>
{room?.parent_area_name ? (
<Text style={styles.areaTag}>{room.parent_area_name}</Text>
) : null}
{room?.area_name ? (
<Text style={styles.areaTag}>{room.area_name}</Text>
) : null}
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.border }]} />
{anchor && (
<View style={styles.anchorRow}>
<Image
source={{ uri: proxyImageUrl(anchor.face) }}
style={styles.avatar}
/>
<Text style={[styles.anchorName, { color: theme.text }]}>{anchor.uname}</Text>
<TouchableOpacity style={styles.followBtn}>
<Text style={styles.followTxt}>+ </Text>
</TouchableOpacity>
</View>
)}
{!!room?.description && (
<View style={styles.descBox}>
<Text style={[styles.descText, { color: theme.text }]}>{room.description}</Text>
</View>
)}
</ScrollView>
<DanmakuList
danmakus={danmakus}
currentTime={999999}
visible
onToggle={() => {}}
style={[styles.danmakuFull, tab !== "danmaku" && styles.hidden]}
hideHeader
isLive
maxItems={500}
giftCounts={giftCounts}
/>
</>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
topBar: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4 },
pipBtn: { padding: 4, width: 32, alignItems: 'center' },
topTitle: {
flex: 1,
fontSize: 15,
fontWeight: "600",
marginLeft: 4,
},
loader: { marginVertical: 30 },
errorText: { textAlign: "center", color: "#f00", padding: 20 },
tabBar: {
flexDirection: "row",
borderBottomWidth: StyleSheet.hairlineWidth,
},
tabItem: {
paddingHorizontal: 20,
paddingVertical: 10,
alignItems: "center",
position: "relative",
},
tabLabel: { fontSize: 14, fontWeight: "500" },
tabActive: { color: "#00AEEC", fontWeight: "700" },
tabUnderline: {
position: "absolute",
bottom: 0,
width: 24,
height: 2,
backgroundColor: "#00AEEC",
borderRadius: 2,
},
scroll: { flex: 1 },
titleSection: { padding: 14 },
title: {
fontSize: 16,
fontWeight: "600",
lineHeight: 22,
marginBottom: 8,
},
metaRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
marginBottom: 8,
},
livePill: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff0f0",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 4,
gap: 4,
},
offlinePill: { backgroundColor: "#f5f5f5" },
liveDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: "#f00" },
livePillText: { fontSize: 12, color: "#f00", fontWeight: "600" },
offlinePillText: { color: "#999" },
onlineRow: { flexDirection: "row", alignItems: "center", gap: 3 },
onlineText: { fontSize: 12, color: "#999" },
areaRow: { flexDirection: "row", gap: 6 },
areaTag: {
fontSize: 11,
color: "#00AEEC",
backgroundColor: "#e8f7fd",
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
},
divider: {
height: StyleSheet.hairlineWidth,
marginHorizontal: 14,
},
anchorRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 12,
},
avatar: { width: 40, height: 40, borderRadius: 20, marginRight: 10 },
anchorName: { flex: 1, fontSize: 14, fontWeight: "500" },
followBtn: {
backgroundColor: "#00AEEC",
paddingHorizontal: 14,
paddingVertical: 5,
borderRadius: 14,
},
followTxt: { color: "#fff", fontSize: 12, fontWeight: "600" },
descBox: { padding: 14, paddingTop: 4 },
descText: { fontSize: 14, lineHeight: 22 },
danmakuFull: { flex: 1 },
hidden: { display: "none" },
});

5
app/live/_layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Slot } from 'expo-router';
export default function LiveLayout() {
return <Slot />;
}

389
app/search.tsx Normal file
View File

@@ -0,0 +1,389 @@
import React, { useRef, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
FlatList,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { VideoCard } from '../components/VideoCard';
import { useSearch, SearchSort } from '../hooks/useSearch';
import { useTheme } from '../utils/theme';
import type { VideoItem } from '../services/types';
const SORT_OPTIONS: { key: SearchSort; label: string }[] = [
{ key: 'default', label: '综合排序' },
{ key: 'pubdate', label: '最新发布' },
{ key: 'view', label: '最多播放' },
];
export default function SearchScreen() {
const router = useRouter();
const {
keyword, setKeyword,
results, loading, hasMore,
search, loadMore,
sort, changeSort,
history, removeFromHistory, clearHistory,
suggestions,
hotSearches,
} = useSearch();
const theme = useTheme();
const inputRef = useRef<TextInput>(null);
const hasResults = results.length > 0;
const hasSearched = hasResults || (loading && results.length === 0);
const handleSearch = useCallback((kw?: string) => {
const term = (kw ?? keyword).trim();
if (term) {
if (kw) setKeyword(kw);
search(kw ?? keyword, true);
}
}, [keyword, search, setKeyword]);
const handleSuggestionPress = useCallback((value: string) => {
setKeyword(value);
search(value, true);
}, [search, setKeyword]);
const renderItem = useCallback(
({ item, index }: { item: VideoItem; index: number }) => {
if (index % 2 !== 0) return null;
const right = results[index + 1];
return (
<View style={styles.row}>
<View style={styles.leftCol}>
<VideoCard
item={item}
onPress={() => router.push(`/video/${item.bvid}` as any)}
/>
</View>
{right ? (
<View style={styles.rightCol}>
<VideoCard
item={right}
onPress={() => router.push(`/video/${right.bvid}` as any)}
/>
</View>
) : (
<View style={styles.rightCol} />
)}
</View>
);
},
[results, router],
);
const keyExtractor = useCallback(
(_: VideoItem, index: number) => String(index),
[],
);
// Show pre-search panel (history + hot searches + suggestions)
const showPreSearch = !hasSearched && !loading;
const showSuggestions = suggestions.length > 0 && keyword.trim().length > 0 && !hasResults;
const ListHeaderComponent = useCallback(() => {
if (!hasResults) return null;
return (
<View style={[styles.sortBar, { backgroundColor: theme.card }]}>
{SORT_OPTIONS.map(opt => (
<TouchableOpacity
key={opt.key}
style={[styles.sortBtn, sort === opt.key && styles.sortBtnActive]}
onPress={() => changeSort(opt.key)}
activeOpacity={0.85}
>
<Text style={[styles.sortBtnText, sort === opt.key && styles.sortBtnTextActive]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
);
}, [hasResults, sort, changeSort, theme.card]);
const ListEmptyComponent = () => {
if (loading) return null;
if (!keyword.trim()) return null;
return (
<View style={styles.emptyBox}>
<Ionicons name="search-outline" size={48} color="#ddd" />
<Text style={[styles.emptyText, { color: theme.textSub }]}></Text>
</View>
);
};
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'left', 'right']}>
{/* Search header */}
<View style={[styles.header, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<View style={[styles.inputWrap, { backgroundColor: theme.inputBg }]}>
<TextInput
ref={inputRef}
style={[styles.input, { color: theme.text }]}
placeholder="搜索视频、UP主..."
placeholderTextColor="#999"
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={() => handleSearch()}
returnKeyType="search"
autoFocus
autoCapitalize="none"
autoCorrect={false}
/>
{keyword.length > 0 && (
<TouchableOpacity onPress={() => setKeyword('')} style={styles.clearBtn}>
<Ionicons name="close-circle" size={16} color="#bbb" />
</TouchableOpacity>
)}
</View>
<TouchableOpacity style={styles.searchBtn} onPress={() => handleSearch()} activeOpacity={0.85}>
<Text style={styles.searchBtnText}></Text>
</TouchableOpacity>
</View>
{/* Suggestions dropdown */}
{showSuggestions && (
<View style={[styles.suggestPanel, { backgroundColor: theme.card }]}>
{suggestions.map((s, i) => (
<TouchableOpacity
key={`${s.value}-${i}`}
style={[styles.suggestItem, { borderBottomColor: theme.border }]}
onPress={() => handleSuggestionPress(s.value)}
activeOpacity={0.85}
>
<Ionicons name="search-outline" size={14} color="#bbb" style={styles.suggestIcon} />
<Text style={[styles.suggestText, { color: theme.text }]} numberOfLines={1}>{s.value}</Text>
</TouchableOpacity>
))}
</View>
)}
{/* Pre-search: history + hot searches */}
{showPreSearch && !showSuggestions ? (
<ScrollView style={styles.preSearch} keyboardShouldPersistTaps="handled">
{/* Search history */}
{history.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.text }]}></Text>
<TouchableOpacity onPress={clearHistory} activeOpacity={0.85}>
<Ionicons name="trash-outline" size={16} color={theme.textSub} />
</TouchableOpacity>
</View>
<View style={styles.tagWrap}>
{history.map(h => (
<TouchableOpacity
key={h}
style={[styles.tag, { backgroundColor: theme.inputBg }]}
onPress={() => handleSearch(h)}
onLongPress={() => removeFromHistory(h)}
activeOpacity={0.85}
>
<Text style={[styles.tagText, { color: theme.text }]} numberOfLines={1}>{h}</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
{/* Hot searches */}
{hotSearches.length > 0 && (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: theme.text }]}></Text>
{hotSearches.map((item, idx) => (
<TouchableOpacity
key={item.keyword}
style={[styles.hotItem, { borderBottomColor: theme.border }]}
onPress={() => handleSearch(item.keyword)}
activeOpacity={0.85}
>
<Text style={[
styles.hotIndex,
idx < 3 && styles.hotIndexTop,
]}>
{idx + 1}
</Text>
<Text style={[styles.hotText, { color: theme.text }]} numberOfLines={1}>
{item.show_name}
</Text>
</TouchableOpacity>
))}
</View>
)}
{history.length === 0 && hotSearches.length === 0 && (
<View style={styles.emptyBox}>
<Ionicons name="search-outline" size={48} color="#ddd" />
<Text style={[styles.emptyText, { color: theme.textSub }]}></Text>
</View>
)}
</ScrollView>
) : (
/* Results list */
<FlatList
data={results}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListHeaderComponent={<ListHeaderComponent />}
ListEmptyComponent={<ListEmptyComponent />}
ListFooterComponent={
loading && results.length > 0 ? (
<View style={styles.footer}>
<ActivityIndicator color="#00AEEC" />
</View>
) : null
}
keyboardShouldPersistTaps="handled"
/>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#f4f4f4' },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 8,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
gap: 6,
},
backBtn: { padding: 4 },
inputWrap: {
flex: 1,
height: 34,
backgroundColor: '#f0f0f0',
borderRadius: 17,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
},
input: {
flex: 1,
fontSize: 14,
color: '#212121',
padding: 0,
},
clearBtn: { paddingLeft: 4 },
searchBtn: {
paddingHorizontal: 10,
paddingVertical: 6,
},
searchBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
// Sort bar
sortBar: {
flexDirection: 'row',
paddingHorizontal: 12,
paddingVertical: 8,
gap: 12,
},
sortBtn: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 14,
},
sortBtnActive: {
backgroundColor: '#00AEEC',
},
sortBtnText: {
fontSize: 12,
color: '#999',
},
sortBtnTextActive: {
color: '#fff',
fontWeight: '600',
},
// Suggestions
suggestPanel: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
},
suggestItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
},
suggestIcon: { marginRight: 8 },
suggestText: { fontSize: 14, flex: 1 },
// Pre-search
preSearch: { flex: 1, paddingHorizontal: 16, paddingTop: 12 },
section: { marginBottom: 20 },
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
sectionTitle: { fontSize: 15, fontWeight: '600', color: '#212121', marginBottom: 2 },
tagWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
tag: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 14,
backgroundColor: '#f0f0f0',
maxWidth: '45%',
},
tagText: { fontSize: 13, color: '#212121' },
// Hot search list
hotItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#eee',
},
hotIndex: {
width: 22,
fontSize: 14,
fontWeight: '600',
color: '#999',
textAlign: 'center',
marginRight: 10,
},
hotIndexTop: { color: '#00AEEC' },
hotText: { fontSize: 14, flex: 1 },
// Results
listContent: { paddingTop: 0, paddingBottom: 20 },
row: {
flexDirection: 'row',
paddingHorizontal: 1,
justifyContent: 'flex-start',
},
leftCol: { flex: 1, marginLeft: 4, marginRight: 2 },
rightCol: { flex: 1, marginLeft: 2, marginRight: 4 },
emptyBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: 80,
gap: 12,
},
emptyText: { fontSize: 14, color: '#bbb' },
footer: { height: 48, alignItems: 'center', justifyContent: 'center' },
});

240
app/settings.tsx Normal file
View File

@@ -0,0 +1,240 @@
import React, { useEffect, useState, useCallback } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } 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';
import { useTheme } from '../utils/theme';
import { useCheckUpdate } from '../hooks/useCheckUpdate';
import { getImageCacheSize, clearImageCache, formatBytes } from '../utils/cache';
export default function SettingsScreen() {
const router = useRouter();
const { isLoggedIn, logout } = useAuthStore();
const { darkMode, setDarkMode, trafficSaving, setTrafficSaving } = useSettingsStore();
const theme = useTheme();
const { currentVersion, isChecking, downloadProgress, checkUpdate } = useCheckUpdate();
const [cacheSize, setCacheSize] = useState<number | null>(null);
const [clearingCache, setClearingCache] = useState(false);
const refreshCacheSize = useCallback(async () => {
const size = await getImageCacheSize();
setCacheSize(size);
}, []);
useEffect(() => {
refreshCacheSize();
}, []);
const handleClearCache = async () => {
Alert.alert('清除缓存', '确定要清除所有缓存吗?', [
{ text: '取消', style: 'cancel' },
{
text: '清除',
style: 'destructive',
onPress: async () => {
setClearingCache(true);
await clearImageCache();
setClearingCache(false);
setCacheSize(0);
Alert.alert('已完成', '缓存已清除');
},
},
]);
};
const handleLogout = async () => {
await logout();
router.back();
};
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
<View style={[styles.topBar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<Text style={[styles.topTitle, { color: theme.text }]}></Text>
<View style={styles.spacer} />
</View>
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.versionRow}>
<Text style={[styles.versionLabel, { color: theme.text }]}></Text>
<Text style={[styles.versionValue, { color: theme.textSub }]}>v{currentVersion}</Text>
</View>
</View>
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<TouchableOpacity
style={styles.updateBtn}
onPress={checkUpdate}
activeOpacity={0.7}
disabled={isChecking || downloadProgress !== null}
>
{isChecking ? (
<>
<ActivityIndicator size="small" color="#00AEEC" style={{ marginRight: 8 }} />
<Text style={styles.updateBtnText}>...</Text>
</>
) : downloadProgress !== null ? (
<Text style={styles.updateBtnText}> {downloadProgress}%</Text>
) : (
<Text style={styles.updateBtnText}></Text>
)}
</TouchableOpacity>
</View>
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.optionRow}>
<TouchableOpacity
style={[styles.option, { backgroundColor: theme.inputBg }, !darkMode && styles.optionActive]}
onPress={() => setDarkMode(false)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, { color: theme.text }, !darkMode && styles.optionTextActive]}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.option, { backgroundColor: theme.inputBg }, darkMode && styles.optionActive]}
onPress={() => setDarkMode(true)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, { color: theme.text }, darkMode && styles.optionTextActive]}></Text>
</TouchableOpacity>
</View>
</View>
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.optionRow}>
<TouchableOpacity
style={[styles.option, { backgroundColor: theme.inputBg }, !trafficSaving && styles.optionActive]}
onPress={() => setTrafficSaving(false)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, { color: theme.text }, !trafficSaving && styles.optionTextActive]}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.option, { backgroundColor: theme.inputBg }, trafficSaving && styles.optionActive]}
onPress={() => setTrafficSaving(true)}
activeOpacity={0.7}
>
<Text style={[styles.optionText, { color: theme.text }, trafficSaving && styles.optionTextActive]}></Text>
</TouchableOpacity>
</View>
{trafficSaving && (
<Text style={[styles.hint, { color: theme.textSub }]}>
· · 360p
</Text>
)}
</View>
<View style={[styles.section, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.sectionLabel, { color: theme.textSub }]}></Text>
<View style={styles.cacheRow}>
<View>
<Text style={[styles.cacheLabel, { color: theme.text }]}></Text>
<Text style={[styles.cacheValue, { color: theme.textSub }]}>
{cacheSize === null ? '计算中...' : formatBytes(cacheSize)}
</Text>
</View>
<TouchableOpacity
style={[styles.clearBtn, clearingCache && { opacity: 0.5 }]}
onPress={handleClearCache}
disabled={clearingCache}
activeOpacity={0.7}
>
{clearingCache
? <ActivityIndicator size="small" color="#fff" />
: <Text style={styles.clearBtnText}></Text>
}
</TouchableOpacity>
</View>
</View>
{isLoggedIn && (
<TouchableOpacity style={[styles.logoutBtn, { borderColor: theme.danger }]} onPress={handleLogout} activeOpacity={0.8}>
<Text style={[styles.logoutText, { color: theme.danger }]}>退</Text>
</TouchableOpacity>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
topBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4, width: 32 },
spacer: { width: 32 },
topTitle: {
flex: 1,
fontSize: 16,
fontWeight: '600',
textAlign: 'center',
},
section: {
marginTop: 16,
paddingHorizontal: 16,
paddingVertical: 14,
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
},
sectionLabel: { fontSize: 13, marginBottom: 10 },
optionRow: { flexDirection: 'row', gap: 10 },
option: {
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 16,
},
optionActive: { backgroundColor: '#00AEEC' },
optionText: { fontSize: 13, fontWeight: '500' },
optionTextActive: { color: '#fff', fontWeight: '600' },
hint: { fontSize: 12, marginTop: 8 },
versionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
versionLabel: { fontSize: 14 },
versionValue: { fontSize: 14 },
updateBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 6,
},
updateBtnText: { fontSize: 14, color: '#00AEEC', fontWeight: '600' },
cacheRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
cacheLabel: { fontSize: 14 },
cacheValue: { fontSize: 12, marginTop: 2 },
clearBtn: {
backgroundColor: '#00AEEC',
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 16,
minWidth: 80,
alignItems: 'center',
},
clearBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
logoutBtn: {
margin: 24,
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
alignItems: 'center',
},
logoutText: { fontSize: 15, fontWeight: '600' },
});

683
app/video/[bvid].tsx Normal file
View File

@@ -0,0 +1,683 @@
import React, { useState, useEffect, useLayoutEffect, useRef } from "react";
import {
View,
Text,
FlatList,
StyleSheet,
TouchableOpacity,
Image,
ActivityIndicator,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { VideoPlayer } from "../../components/VideoPlayer";
import { CommentItem } from "../../components/CommentItem";
import { getDanmaku, getUploaderStat } from "../../services/api";
import { DanmakuItem } from "../../services/types";
import DanmakuList from "../../components/DanmakuList";
import { useVideoDetail } from "../../hooks/useVideoDetail";
import { useComments } from "../../hooks/useComments";
import { useRelatedVideos } from "../../hooks/useRelatedVideos";
import { formatCount, formatDuration } from "../../utils/format";
import { proxyImageUrl } from "../../utils/imageUrl";
import { DownloadSheet } from "../../components/DownloadSheet";
import { useTheme } from "../../utils/theme";
import { useLiveStore } from "../../store/liveStore";
type Tab = "intro" | "comments" | "danmaku";
export default function VideoDetailScreen() {
const { bvid } = useLocalSearchParams<{ bvid: string }>();
const router = useRouter();
const theme = useTheme();
// 进入视频详情页时立即清除直播小窗
useLayoutEffect(() => {
useLiveStore.getState().clearLive();
}, []);
const {
video,
playData,
loading: videoLoading,
qualities,
currentQn,
changeQuality,
} = useVideoDetail(bvid as string);
const [commentSort, setCommentSort] = useState<0 | 2>(2);
const {
comments,
loading: cmtLoading,
hasMore: cmtHasMore,
load: loadComments,
} = useComments(video?.aid ?? 0, commentSort);
const [tab, setTab] = useState<Tab>("intro");
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [currentTime, setCurrentTime] = useState(0);
const [showDownload, setShowDownload] = useState(false);
const [uploaderStat, setUploaderStat] = useState<{
follower: number;
archiveCount: number;
} | null>(null);
const {
videos: relatedVideos,
loading: relatedLoading,
load: loadRelated,
} = useRelatedVideos(bvid as string);
useEffect(() => {
loadRelated();
}, []);
useEffect(() => {
if (video?.aid) loadComments();
}, [video?.aid, commentSort]);
useEffect(() => {
if (!video?.cid) return;
getDanmaku(video.cid).then(setDanmakus).catch(() => {});
}, [video?.cid]);
useEffect(() => {
if (!video?.owner?.mid) return;
getUploaderStat(video.owner.mid)
.then(setUploaderStat)
.catch(() => {});
}, [video?.owner?.mid]);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.card }]}>
{/* TopBar */}
<View style={[styles.topBar, { borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={theme.text} />
</TouchableOpacity>
<Text
style={[styles.topTitle, { color: theme.text }]}
numberOfLines={1}
>
{video?.title ?? "视频详情"}
</Text>
<TouchableOpacity
style={styles.miniBtn}
onPress={() => setShowDownload(true)}
>
<Ionicons
name="cloud-download-outline"
size={22}
color={theme.text}
/>
</TouchableOpacity>
</View>
{/* Video player — fixed 16:9 */}
<VideoPlayer
playData={playData}
qualities={qualities}
currentQn={currentQn}
onQualityChange={changeQuality}
bvid={bvid as string}
cid={video?.cid}
danmakus={danmakus}
onTimeUpdate={setCurrentTime}
/>
<DownloadSheet
visible={showDownload}
onClose={() => setShowDownload(false)}
bvid={bvid as string}
cid={video?.cid ?? 0}
title={video?.title ?? ""}
cover={video?.pic ?? ""}
qualities={qualities}
/>
{/* TabBar */}
{video && (
<View style={[styles.tabBar, { borderBottomColor: theme.border }]}>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("intro")}
>
<Text
style={[
styles.tabLabel,
{ color: theme.textSub },
tab === "intro" && styles.tabActive,
]}
>
</Text>
{tab === "intro" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("comments")}
>
<Text
style={[
styles.tabLabel,
{ color: theme.textSub },
tab === "comments" && styles.tabActive,
]}
>
{video.stat?.reply > 0 ? ` ${formatCount(video.stat.reply)}` : ""}
</Text>
{tab === "comments" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
<TouchableOpacity
style={styles.tabItem}
onPress={() => setTab("danmaku")}
>
<Text
style={[
styles.tabLabel,
{ color: theme.textSub },
tab === "danmaku" && styles.tabActive,
]}
>
{danmakus.length > 0 ? ` ${formatCount(danmakus.length)}` : ""}
</Text>
{tab === "danmaku" && <View style={styles.tabUnderline} />}
</TouchableOpacity>
</View>
)}
{/* Tab content */}
{videoLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : video ? (
<>
{tab === "intro" && (
<FlatList<import("../../services/types").VideoItem>
style={styles.tabScroll}
data={relatedVideos}
keyExtractor={(item) => item.bvid}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<>
<TouchableOpacity
style={styles.upRow}
activeOpacity={0.85}
onPress={() => router.push(`/creator/${video.owner.mid}` as any)}
>
<Image
source={{ uri: proxyImageUrl(video.owner.face) }}
style={styles.avatar}
/>
<View style={styles.upInfo}>
<Text style={[styles.upName, { color: theme.text }]}>
{video.owner.name}
</Text>
{uploaderStat && (
<Text style={styles.upStat}>
{formatCount(uploaderStat.follower)} ·{" "}
{formatCount(uploaderStat.archiveCount)}
</Text>
)}
</View>
<View style={styles.followBtn}>
<Text style={styles.followTxt}></Text>
</View>
</TouchableOpacity>
<View
style={[
styles.titleSection,
{ borderBottomColor: theme.border },
]}
>
<Text style={[styles.title, { color: theme.text }]}>
{video.title}
</Text>
<Text style={[styles.subTitle, { color: theme.text }]}>
{video.desc || "暂无简介"}
</Text>
<View style={styles.statsRow}>
<StatBadge icon="play" count={video.stat.view} />
<StatBadge icon="heart" count={video.stat.like} />
<StatBadge icon="star" count={video.stat.favorite} />
<StatBadge icon="chatbubble" count={video.stat.reply} />
</View>
</View>
{video.ugc_season && (
<SeasonSection
season={video.ugc_season}
currentBvid={bvid as string}
onEpisodePress={(epBvid) =>
router.replace(`/video/${epBvid}`)
}
/>
)}
<View
style={[
styles.relatedHeader,
{
backgroundColor: theme.card,
},
]}
>
<Text
style={[styles.relatedHeaderText, { color: theme.text }]}
>
</Text>
</View>
</>
}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.relatedCard,
{
backgroundColor: theme.card,
borderBottomColor: theme.border,
},
]}
onPress={() => router.replace(`/video/${item.bvid}` as any)}
activeOpacity={0.85}
>
<View
style={[
styles.relatedThumbWrap,
{ backgroundColor: theme.card },
]}
>
<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, { color: theme.text }]}
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}
keyExtractor={(c) => String(c.rpid)}
renderItem={({ item }) => <CommentItem item={item} />}
onEndReached={() => {
if (cmtHasMore && !cmtLoading) loadComments();
}}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View
style={[styles.sortRow, { borderBottomColor: theme.border }]}
>
<TouchableOpacity
style={[
styles.sortBtn,
commentSort === 2 && styles.sortBtnActive,
]}
onPress={() => setCommentSort(2)}
>
<Text
style={[
styles.sortBtnTxt,
commentSort === 2 && styles.sortBtnTxtActive,
]}
>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.sortBtn,
commentSort === 0 && styles.sortBtnActive,
]}
onPress={() => setCommentSort(0)}
>
<Text
style={[
styles.sortBtnTxt,
commentSort === 0 && styles.sortBtnTxtActive,
]}
>
</Text>
</TouchableOpacity>
</View>
}
ListFooterComponent={
cmtLoading ? (
<ActivityIndicator style={styles.loader} color="#00AEEC" />
) : !cmtHasMore && comments.length > 0 ? (
<Text style={styles.emptyTxt}></Text>
) : null
}
ListEmptyComponent={
!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>
);
}
function StatBadge({ icon, count }: { icon: string; count: number }) {
return (
<View style={styles.stat}>
<Ionicons name={icon as any} size={14} color="#999" />
<Text style={styles.statText}>{formatCount(count)}</Text>
</View>
);
}
function SeasonSection({
season,
currentBvid,
onEpisodePress,
}: {
season: NonNullable<import("../../services/types").VideoItem["ugc_season"]>;
currentBvid: string;
onEpisodePress: (bvid: string) => void;
}) {
const theme = useTheme();
const episodes = season.sections?.[0]?.episodes ?? [];
const currentIndex = episodes.findIndex((ep) => ep.bvid === currentBvid);
const listRef = useRef<FlatList>(null);
useEffect(() => {
if (currentIndex <= 0 || episodes.length === 0) return;
const t = setTimeout(() => {
listRef.current?.scrollToIndex({
index: currentIndex,
viewPosition: 0.5,
animated: false,
});
}, 200);
return () => clearTimeout(t);
}, [currentIndex, episodes.length]);
return (
<View
style={[
styles.seasonBox,
{ borderTopColor: theme.border, backgroundColor: theme.card },
]}
>
<View style={styles.seasonHeader}>
<Text style={[styles.seasonTitle, { color: theme.text }]}>
· {season.title}
</Text>
<Text style={styles.seasonCount}>{season.ep_count}</Text>
<Ionicons name="chevron-forward" size={14} color="#999" />
</View>
<FlatList
ref={listRef}
horizontal
showsHorizontalScrollIndicator={false}
data={episodes}
keyExtractor={(ep) => ep.bvid}
contentContainerStyle={{ paddingHorizontal: 12, gap: 10 }}
getItemLayout={(_data, index) => ({
length: 130,
offset: 12 + index * 130,
index,
})}
onScrollToIndexFailed={() => {}}
renderItem={({ item: ep, index }) => {
const isCurrent = ep.bvid === currentBvid;
return (
<TouchableOpacity
style={[
styles.epCard,
{ backgroundColor: theme.card, borderColor: theme.border },
isCurrent && styles.epCardActive,
]}
onPress={() => !isCurrent && onEpisodePress(ep.bvid)}
activeOpacity={0.8}
>
{ep.arc?.pic && (
<Image
source={{ uri: proxyImageUrl(ep.arc.pic) }}
style={[styles.epThumb, { backgroundColor: theme.card }]}
/>
)}
<Text style={[styles.epNum, isCurrent && styles.epNumActive]}>
{index + 1}
</Text>
<Text
style={[styles.epTitle, { color: theme.text }]}
numberOfLines={2}
>
{ep.title}
</Text>
</TouchableOpacity>
);
}}
/>
</View>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
topBar: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
backBtn: { padding: 4 },
topTitle: {
flex: 1,
fontSize: 15,
fontWeight: "600",
marginLeft: 4,
},
miniBtn: { padding: 4 },
loader: { marginVertical: 30 },
titleSection: {
padding: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
title: {
fontSize: 13,
fontWeight: "600",
lineHeight: 22,
marginBottom: 8,
},
subTitle: {
fontSize: 10,
marginBottom: 8,
},
statsRow: { flexDirection: "row", gap: 16 },
stat: { flexDirection: "row", alignItems: "center", gap: 3 },
statText: { fontSize: 12, color: "#999" },
upRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 10,
paddingBottom: 0,
paddingTop: 12,
},
avatar: { width: 40, height: 40, borderRadius: 30, marginRight: 10 },
upInfo: { flex: 1, justifyContent: "center" },
upName: { fontSize: 14, fontWeight: "500" },
upStat: { fontSize: 11, color: "#999", marginTop: 2 },
followBtn: {
backgroundColor: "#00AEEC",
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 14,
},
followTxt: { color: "#fff", fontSize: 12, fontWeight: "500" },
seasonBox: {
borderTopWidth: StyleSheet.hairlineWidth,
paddingVertical: 10,
},
seasonHeader: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingBottom: 8,
gap: 4,
},
seasonTitle: { flex: 1, fontSize: 13, fontWeight: "600" },
seasonCount: { fontSize: 12, color: "#999" },
epCard: {
width: 120,
borderRadius: 6,
overflow: "hidden",
borderWidth: 1,
borderColor: "transparent",
},
epCardActive: { borderColor: "#00AEEC", borderWidth: 1.5 },
epThumb: { width: 120, height: 68 },
epNum: { fontSize: 11, color: "#999", paddingHorizontal: 6, paddingTop: 4 },
epNumActive: { color: "#00AEEC", fontWeight: "600" },
epTitle: {
fontSize: 12,
paddingHorizontal: 6,
paddingBottom: 6,
lineHeight: 16,
},
tabBar: {
flexDirection: "row",
borderBottomWidth: StyleSheet.hairlineWidth,
paddingLeft: 3,
},
tabItem: {
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 12,
position: "relative",
},
tabLabel: { fontSize: 13 },
tabActive: { color: "#00AEEC" },
tabUnderline: {
position: "absolute",
bottom: 0,
width: 24,
height: 2,
backgroundColor: "#00AEEC",
borderRadius: 1,
},
tabScroll: { flex: 1 },
descBox: { padding: 16 },
descText: { fontSize: 14, lineHeight: 22 },
danmakuTab: { flex: 1 },
emptyTxt: { textAlign: "center", color: "#bbb", padding: 30 },
relatedHeader: {
paddingLeft: 13,
paddingBottom: 8,
paddingTop: 8,
},
relatedHeaderText: {
fontSize: 13,
fontWeight: "600" as const,
},
relatedCard: {
flexDirection: "row",
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 10,
},
relatedThumbWrap: {
position: "relative",
width: 120,
height: 68,
borderRadius: 4,
overflow: "hidden",
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, lineHeight: 18 },
relatedOwner: { fontSize: 12, color: "#999" },
relatedView: { fontSize: 11, color: "#bbb" },
sortRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: 14,
paddingVertical: 10,
gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
sortBtn: {
paddingHorizontal: 10,
paddingVertical: 2,
borderRadius: 16,
backgroundColor: "#f0f0f0",
},
sortBtnActive: { backgroundColor: "#00AEEC" },
sortBtnTxt: { fontSize: 13, color: "#333", fontWeight: "500" },
sortBtnTxtActive: { color: "#fff", fontWeight: "600" as const },
});

5
app/video/_layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Slot } from 'expo-router';
export default function VideoLayout() {
return <Slot />;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

BIN
assets/splash-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

22
build-apk.ps1 Normal file
View File

@@ -0,0 +1,22 @@
$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
$env:ANDROID_HOME = "C:\Users\Administrator\AppData\Local\Android\Sdk"
$env:PATH = "$env:JAVA_HOME\bin;C:\nvm4w\nodejs;$env:ANDROID_HOME\platform-tools;$env:PATH"
# Create short path alias to avoid 260-char limit
subst Z: "C:\Users\Administrator\Desktop\claude code studly\reactJKVideoApp" 2>$null
Set-Location "Z:\android"
.\gradlew.bat assembleDebug --no-daemon
Write-Host ""
if ($LASTEXITCODE -eq 0) {
$apk = Get-ChildItem "Z:\android\app\build\outputs\apk\debug\*.apk" | Select-Object -First 1
Write-Host "SUCCESS! APK: $($apk.FullName)" -ForegroundColor Green
# Copy to Desktop
Copy-Item $apk.FullName "C:\Users\Administrator\Desktop\JKVideo-debug.apk"
Write-Host "Copied to Desktop: JKVideo-debug.apk" -ForegroundColor Cyan
} else {
Write-Host "Build FAILED" -ForegroundColor Red
}
subst Z: /D 2>$null

426
components/BigVideoCard.tsx Normal file
View File

@@ -0,0 +1,426 @@
// components/BigVideoCard.tsx
import React, {
useEffect,
useRef,
useState,
useCallback,
useMemo,
} from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
useWindowDimensions,
Animated,
PanResponder,
} from "react-native";
import { Image } from "expo-image";
import Video, { VideoRef } from "react-native-video";
import { Ionicons } from "@expo/vector-icons";
import { buildDashMpdUri } from "../utils/dash";
import { getPlayUrl, getVideoDetail } from "../services/api";
import { coverImageUrl } from "../utils/imageUrl";
import { useSettingsStore } from "../store/settingsStore";
import { useTheme } from "../utils/theme";
import { useLiveStore } from "../store/liveStore";
import { formatCount, formatDuration } from "../utils/format";
import type { VideoItem } from "../services/types";
const HEADERS = {};
const BAR_H = 3;
// Minimum horizontal distance (px) before treating the gesture as a seek
const SWIPE_THRESHOLD = 8;
// Full swipe across the screen = seek this many seconds
const SWIPE_SECONDS = 90;
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
interface Props {
item: VideoItem;
isVisible: boolean;
isScrolling?: boolean;
onPress: () => void;
}
export const BigVideoCard = React.memo(function BigVideoCard({
item,
isVisible,
isScrolling,
onPress,
}: Props) {
const { width: SCREEN_W } = useWindowDimensions();
const trafficSaving = useSettingsStore(s => s.trafficSaving);
const liveActive = useLiveStore(s => s.isActive);
const theme = useTheme();
const THUMB_H = SCREEN_W * 0.5625;
const mediaDimensions = { width: SCREEN_W - 8, height: THUMB_H };
const [videoUrl, setVideoUrl] = useState<string | undefined>();
const [isDash, setIsDash] = useState(false);
const [paused, setPaused] = useState(true);
const [muted, setMuted] = useState(true);
const thumbOpacity = useRef(new Animated.Value(1)).current;
const videoRef = useRef<VideoRef>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
// Refs for PanResponder (avoid stale closures)
const currentTimeRef = useRef(0);
const durationRef = useRef(0);
const seekingRef = useRef(false);
const [seekLabel, setSeekLabel] = useState<string | null>(null);
// Reset video state when the item changes
useEffect(() => {
setVideoUrl(undefined);
setIsDash(false);
setPaused(true);
setMuted(true);
setCurrentTime(0);
setDuration(0);
setBuffered(0);
thumbOpacity.setValue(1);
}, [item.bvid]);
// Preload: fetch play URL on mount (before card is visible)
useEffect(() => {
if (videoUrl || trafficSaving || liveActive) return;
let cancelled = false;
(async () => {
try {
let cid = item.cid;
if (!cid) {
const detail = await getVideoDetail(item.bvid);
cid = detail.cid ?? detail.pages?.[0]?.cid;
}
if (!cid) {
console.warn('BigVideoCard: no cid available for', item.bvid);
return;
}
if (cancelled) return;
const playData = await getPlayUrl(item.bvid, cid, 16);
if (cancelled) return;
if (playData.dash) {
if (!cancelled) setIsDash(true);
try {
const mpdUri = await buildDashMpdUri(playData, 16);
if (!cancelled) setVideoUrl(mpdUri);
} catch {
if (!cancelled) setVideoUrl(playData.dash.video[0]?.baseUrl);
}
} else {
if (!cancelled) setVideoUrl(playData.durl?.[0]?.url);
}
} catch (e) {
console.warn("BigVideoCard: failed to load play URL", e);
}
})();
return () => {
cancelled = true;
};
}, [item.bvid]);
// Pause/resume based on visibility and scroll state
useEffect(() => {
if (!videoUrl) return;
if (!isVisible || trafficSaving || liveActive) {
setPaused(true);
setMuted(true);
Animated.timing(thumbOpacity, {
toValue: 1,
duration: 150,
useNativeDriver: true,
}).start();
} else if (isScrolling) {
setPaused(true);
} else {
setPaused(false);
Animated.timing(thumbOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start();
}
}, [isVisible, isScrolling, videoUrl, trafficSaving, liveActive]);
const handleVideoReady = () => {
if (!isVisible || isScrolling || trafficSaving || liveActive) return;
setPaused(false);
Animated.timing(thumbOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start();
};
// Keep refs in sync
useEffect(() => {
currentTimeRef.current = currentTime;
}, [currentTime]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
// Horizontal swipe to seek
const swipeStartTime = useRef(0);
const screenWRef = useRef(SCREEN_W);
useEffect(() => {
screenWRef.current = SCREEN_W;
}, [SCREEN_W]);
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_, gs) =>
Math.abs(gs.dx) > SWIPE_THRESHOLD &&
Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5,
onPanResponderGrant: () => {
seekingRef.current = true;
swipeStartTime.current = currentTimeRef.current;
},
onMoveShouldSetPanResponderCapture: (_, gs) =>
Math.abs(gs.dx) > SWIPE_THRESHOLD &&
Math.abs(gs.dx) > Math.abs(gs.dy) * 1.5,
onPanResponderMove: (_, gs) => {
if (durationRef.current <= 0) return;
const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS;
const target = clamp(
swipeStartTime.current + delta,
0,
durationRef.current,
);
setSeekLabel(formatDuration(Math.floor(target)));
},
onPanResponderRelease: (_, gs) => {
if (durationRef.current > 0) {
const delta = (gs.dx / screenWRef.current) * SWIPE_SECONDS;
const target = clamp(
swipeStartTime.current + delta,
0,
durationRef.current,
);
videoRef.current?.seek(target);
setCurrentTime(target);
}
seekingRef.current = false;
setSeekLabel(null);
},
onPanResponderTerminate: () => {
seekingRef.current = false;
setSeekLabel(null);
},
}),
[],
);
const progressRatio = duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
return (
<View style={[styles.card, { backgroundColor: theme.card }]}>
{/* Media area */}
<View style={[mediaDimensions, { position: "relative" }]}>
{/* Video player — rendered first so it sits behind the thumbnail */}
{videoUrl && !liveActive && (
<Video
ref={videoRef}
source={
isDash
? { uri: videoUrl, type: "mpd", headers: HEADERS }
: { uri: videoUrl, headers: HEADERS }
}
style={StyleSheet.absoluteFill}
resizeMode="cover"
muted={muted}
paused={paused || seekingRef.current}
repeat
controls={false}
onReadyForDisplay={handleVideoReady}
onProgress={({
currentTime: ct,
seekableDuration: dur,
playableDuration: buf,
}) => {
if (!seekingRef.current) setCurrentTime(ct);
if (dur > 0) setDuration(dur);
setBuffered(buf);
}}
/>
)}
{/* Thumbnail — on top of Video, fades out once video is ready */}
<Animated.View
style={[StyleSheet.absoluteFill, { opacity: thumbOpacity }]}
pointerEvents="none"
>
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={mediaDimensions}
contentFit="cover"
recyclingKey={item.bvid}
/>
</Animated.View>
{/* Swipe gesture layer */}
<View
style={[StyleSheet.absoluteFill, { zIndex: 5 }]}
{...panResponder.panHandlers}
/>
{/* Seek time label */}
{seekLabel && (
<View style={styles.seekBadge}>
<Text style={styles.seekText}>
{seekLabel} / {formatDuration(durationRef.current)}
</Text>
</View>
)}
<View style={styles.meta}>
<Ionicons name="play" size={11} color="#fff" />
<Text style={styles.metaText}>
{formatCount(item.stat?.view ?? 0)}
</Text>
</View>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>
{formatDuration(item.duration)}
</Text>
</View>
{/* Mute toggle — visible only when video is playing */}
{videoUrl && !paused && (
<TouchableOpacity
style={styles.muteBtn}
onPress={() => setMuted((m) => !m)}
activeOpacity={0.7}
>
<Ionicons
name={muted ? "volume-mute" : "volume-high"}
size={16}
color="#fff"
/>
</TouchableOpacity>
)}
</View>
{/* Progress bar between video and info — always rendered to avoid height jump */}
<View style={styles.progressTrack}>
{videoUrl && duration > 0 && (
<>
<View
style={[
styles.progressLayer,
{
width: `${bufferedRatio * 100}%` as any,
backgroundColor: "rgba(0,174,236,0.25)",
},
]}
/>
<View
style={[
styles.progressLayer,
{
width: `${progressRatio * 100}%` as any,
backgroundColor: "#00AEEC",
},
]}
/>
</>
)}
</View>
{/* Info */}
<TouchableOpacity onPress={onPress} activeOpacity={0.85}>
<View style={styles.info}>
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{item.title}
</Text>
<Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
{item.owner?.name ?? ""}
</Text>
</View>
</TouchableOpacity>
</View>
);
});
const styles = StyleSheet.create({
card: {
marginHorizontal: 4,
marginBottom: 6,
backgroundColor: "#fff",
borderRadius: 6,
overflow: "hidden",
},
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 },
muteBtn: {
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
zIndex: 6,
},
seekBadge: {
position: "absolute",
top: "40%",
alignSelf: "center",
backgroundColor: "rgba(0,0,0,0.6)",
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 6,
zIndex: 4,
},
seekText: { color: "#fff", fontSize: 16, fontWeight: "600" },
progressTrack: {
height: BAR_H,
backgroundColor: "rgba(0,0,0,0.08)",
position: "relative",
},
progressLayer: {
position: "absolute",
top: 0,
left: 0,
height: BAR_H,
},
info: { padding: 8 },
title: { fontSize: 14, color: "#212121", lineHeight: 18, marginBottom: 4 },
meta: {
position: "absolute",
bottom: 4,
left: 4,
paddingHorizontal: 4,
borderRadius: 5,
backgroundColor: "rgba(0,0,0,0.6)",
flexDirection: "row",
alignItems: "center",
paddingVertical: 0,
gap: 2,
zIndex: 2,
},
metaText: { fontSize: 10, color: "#fff" },
owner: { fontSize: 11, color: "#999", marginTop: 2 },
});

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import type { Comment } from '../services/types';
import { formatTime } from '../utils/format';
import { proxyImageUrl } from '../utils/imageUrl';
import { useTheme } from '../utils/theme';
interface Props { item: Comment; }
export function CommentItem({ item }: Props) {
const theme = useTheme();
return (
<View style={[styles.row, { borderBottomColor: theme.border }]}>
<Image source={{ uri: proxyImageUrl(item.member.avatar) }} style={styles.avatar} />
<View style={styles.content}>
<Text style={styles.username}>{item.member.uname}</Text>
<Text style={[styles.message, { color: theme.text }]}>{item.content.message}</Text>
<View style={styles.footer}>
<Text style={[styles.time, { color: theme.textSub }]}>{formatTime(item.ctime)}</Text>
<View style={styles.likeRow}>
<Ionicons name="thumbs-up-outline" size={12} color={theme.textSub} />
<Text style={[styles.likeCount, { color: theme.textSub }]}>{item.like > 0 ? item.like : ''}</Text>
</View>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#eee' },
avatar: { width: 34, height: 34, borderRadius: 17, marginRight: 10 },
content: { flex: 1 },
username: { fontSize: 12, color: '#00AEEC', marginBottom: 3 },
message: { fontSize: 14, color: '#212121', lineHeight: 20 },
footer: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 },
time: { fontSize: 11, color: '#bbb' },
likeRow: { flexDirection: 'row', alignItems: 'center', gap: 2 },
likeCount: { fontSize: 11, color: '#999' },
});

604
components/DanmakuList.tsx Normal file
View File

@@ -0,0 +1,604 @@
import React, { useRef, useState, useEffect, useCallback } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Animated,
NativeSyntheticEvent,
NativeScrollEvent,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { DanmakuItem } from "../services/types";
import { danmakuColorToCss } from "../utils/danmaku";
import { useTheme } from "../utils/theme";
interface Props {
danmakus: DanmakuItem[];
currentTime: number;
visible: boolean;
onToggle: () => void;
style?: object | object[];
hideHeader?: boolean;
isLive?: boolean;
maxItems?: number;
giftCounts?: Record<string, number>;
}
interface DisplayedDanmaku extends DanmakuItem {
_key: number;
_fadeAnim: Animated.Value;
}
const DRIP_INTERVAL = 250;
const FAST_DRIP_INTERVAL = 100;
const QUEUE_FAST_THRESHOLD = 50;
const SEEK_THRESHOLD = 2;
// ─── Animated.Value 对象池,减少频繁创建/GC ──────────────────────────────────
const animPool: Animated.Value[] = [];
const POOL_MAX = 64;
function acquireAnim(): Animated.Value {
const v = animPool.pop();
if (v) { v.setValue(0); return v; }
return new Animated.Value(0);
}
function releaseAnims(items: DisplayedDanmaku[]) {
for (const item of items) {
if (animPool.length < POOL_MAX) {
animPool.push(item._fadeAnim);
}
}
}
// ─── 舰长等级 ───────────────────────────────────────────────────────────────────
const GUARD_LABELS: Record<number, { text: string; color: string }> = {
1: { text: "总督", color: "#E13979" },
2: { text: "提督", color: "#7B68EE" },
3: { text: "舰长", color: "#00D1F1" },
};
function formatTimestamp(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function formatLiveTime(timeline?: string): string {
if (!timeline) return "";
const parts = timeline.split(" ");
return parts[1]?.slice(0, 5) ?? ""; // "HH:MM"
}
export default function DanmakuList({
danmakus,
currentTime,
visible,
onToggle,
style,
hideHeader,
isLive,
maxItems = 100,
giftCounts,
}: Props) {
const theme = useTheme();
const flatListRef = useRef<FlatList>(null);
const [displayedItems, setDisplayedItems] = useState<DisplayedDanmaku[]>([]);
const [unseenCount, setUnseenCount] = useState(0);
const queueRef = useRef<DanmakuItem[]>([]);
const lastTimeRef = useRef(0);
const processedIndexRef = useRef(0);
const keyCounterRef = useRef(0);
const isAtBottomRef = useRef(true);
const danmakusRef = useRef(danmakus);
// Detect changes in the danmakus array
useEffect(() => {
const prev = danmakusRef.current;
if (prev === danmakus) return;
danmakusRef.current = danmakus;
if (danmakus.length === 0) {
queueRef.current = [];
processedIndexRef.current = 0;
lastTimeRef.current = 0;
setDisplayedItems([]);
setUnseenCount(0);
isAtBottomRef.current = true;
return;
}
if (isLive) {
const newStart = processedIndexRef.current;
if (danmakus.length > newStart) {
queueRef.current.push(...danmakus.slice(newStart));
processedIndexRef.current = danmakus.length;
}
return;
}
queueRef.current = [];
processedIndexRef.current = 0;
lastTimeRef.current = 0;
setDisplayedItems([]);
setUnseenCount(0);
isAtBottomRef.current = true;
}, [danmakus, isLive]);
// Watch currentTime — only used in video mode
useEffect(() => {
if (danmakus.length === 0 || isLive) return;
const prevTime = lastTimeRef.current;
lastTimeRef.current = currentTime;
if (Math.abs(currentTime - prevTime) > SEEK_THRESHOLD) {
queueRef.current = [];
processedIndexRef.current = 0;
setDisplayedItems([]);
setUnseenCount(0);
isAtBottomRef.current = true;
const catchUp = danmakus.filter((d) => d.time <= currentTime);
const tail = catchUp.slice(-20);
queueRef.current = tail;
processedIndexRef.current = danmakus.findIndex(
(d) => d.time > currentTime,
);
if (processedIndexRef.current === -1) {
processedIndexRef.current = danmakus.length;
}
return;
}
const sorted = danmakus;
let i = processedIndexRef.current;
while (i < sorted.length && sorted[i].time <= currentTime) {
queueRef.current.push(sorted[i]);
i++;
}
processedIndexRef.current = i;
}, [currentTime, danmakus, isLive]);
// Drip interval — always running so queue is consumed even when tab is hidden
useEffect(() => {
const id = setInterval(
() => {
if (queueRef.current.length === 0) return;
const item = queueRef.current.shift()!;
const fadeAnim = acquireAnim();
const displayed: DisplayedDanmaku = {
...item,
_key: keyCounterRef.current++,
_fadeAnim: fadeAnim,
};
Animated.timing(fadeAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start();
setDisplayedItems((prev) => {
const next = [...prev, displayed];
if (next.length > maxItems) {
const trimCount = next.length - Math.floor(maxItems / 2);
const trimmed = next.slice(trimCount);
releaseAnims(next.slice(0, trimCount));
return trimmed;
}
return next;
});
if (isAtBottomRef.current) {
requestAnimationFrame(() => {
flatListRef.current?.scrollToEnd({ animated: true });
});
} else {
setUnseenCount((c) => c + 1);
}
},
queueRef.current.length > QUEUE_FAST_THRESHOLD
? FAST_DRIP_INTERVAL
: DRIP_INTERVAL,
);
return () => clearInterval(id);
}, []);
const handleScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
const distanceFromBottom =
contentSize.height - layoutMeasurement.height - contentOffset.y;
isAtBottomRef.current = distanceFromBottom < 40;
if (isAtBottomRef.current) setUnseenCount(0);
},
[],
);
const handleScrollBeginDrag = useCallback(() => {
isAtBottomRef.current = false;
}, []);
const handlePillPress = useCallback(() => {
flatListRef.current?.scrollToEnd({ animated: true });
setUnseenCount(0);
isAtBottomRef.current = true;
}, []);
// ─── Live mode render (live chat) ─────────────────────────────────────
const renderLiveItem = useCallback(
({ item }: { item: DisplayedDanmaku }) => {
const guard = item.guardLevel ? GUARD_LABELS[item.guardLevel] : null;
const timeStr = formatLiveTime(item.timeline);
return (
<Animated.View
style={[
liveStyles.row,
{ opacity: item._fadeAnim, borderBottomColor: theme.border },
]}
>
{timeStr ? <Text style={liveStyles.time}>{timeStr}</Text> : null}
<View style={liveStyles.msgBody}>
{guard && (
<View
style={[liveStyles.guardTag, { backgroundColor: guard.color }]}
>
<Text style={liveStyles.guardTagText}>{guard.text}</Text>
</View>
)}
{item.isAdmin && (
<View
style={[liveStyles.guardTag, { backgroundColor: "#e53935" }]}
>
<Text style={liveStyles.guardTagText}></Text>
</View>
)}
{item.medalLevel != null && item.medalName && (
<View style={liveStyles.medalTag}>
<Text style={liveStyles.medalName}>{item.medalName}</Text>
<View style={liveStyles.medalLvBox}>
<Text style={[liveStyles.medalLv, { color: theme.text }]}>
{item.medalLevel}
</Text>
</View>
</View>
)}
<Text style={liveStyles.uname} numberOfLines={1}>
{item.uname ?? "匿名"}
</Text>
<Text style={liveStyles.colon}></Text>
<Text
style={[liveStyles.text, { color: theme.text }]}
numberOfLines={2}
>
{item.text}
</Text>
</View>
</Animated.View>
);
},
[theme],
);
// ─── Video mode render (original bubble) ───────────────────────────────────
const renderVideoItem = useCallback(
({ item }: { item: DisplayedDanmaku }) => {
const dotColor = danmakuColorToCss(item.color);
return (
<Animated.View
style={[
styles.bubble,
{ opacity: item._fadeAnim, backgroundColor: theme.bg },
]}
>
<Text
style={[styles.bubbleText, { color: theme.text }]}
numberOfLines={3}
>
{item.text}
</Text>
<Text style={styles.timestamp}>{formatTimestamp(item.time)}</Text>
</Animated.View>
);
},
[theme],
);
const keyExtractor = useCallback(
(item: DisplayedDanmaku) => String(item._key),
[],
);
return (
<View
style={[
styles.container,
{ backgroundColor: theme.card, borderTopColor: theme.border },
style,
]}
>
{!hideHeader && (
<TouchableOpacity
style={styles.header}
onPress={onToggle}
activeOpacity={0.7}
>
<Ionicons
name={visible ? "chatbubbles" : "chatbubbles-outline"}
size={16}
color="#00AEEC"
/>
<Text style={styles.headerText}>
{danmakus.length > 0 ? `(${danmakus.length})` : ""}
</Text>
<Ionicons
name={visible ? "chevron-up" : "chevron-down"}
size={14}
color="#999"
/>
</TouchableOpacity>
)}
{visible && (
<View style={styles.listWrapper}>
<FlatList
ref={flatListRef}
data={displayedItems}
keyExtractor={keyExtractor}
renderItem={isLive ? renderLiveItem : renderVideoItem}
style={[
isLive ? liveStyles.list : styles.list,
{ backgroundColor: theme.card },
]}
contentContainerStyle={
isLive ? liveStyles.listContent : styles.listContent
}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
scrollEventThrottle={16}
removeClippedSubviews={true}
ListEmptyComponent={
<Text style={styles.empty}>
{danmakus.length === 0 ? "暂无弹幕" : "弹幕将随视频播放显示"}
</Text>
}
/>
{unseenCount > 0 && (
<TouchableOpacity
style={styles.pill}
onPress={handlePillPress}
activeOpacity={0.8}
>
<Text style={styles.pillText}>{unseenCount} </Text>
</TouchableOpacity>
)}
</View>
)}
</View>
);
}
// ─── Video mode styles ────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: {
backgroundColor: "#fff",
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#eee",
},
header: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 8,
gap: 6,
},
headerText: {
flex: 1,
fontSize: 13,
color: "#212121",
fontWeight: "500",
},
listWrapper: {
flex: 1,
position: "relative",
},
list: {
flex: 1,
backgroundColor: "#fafafa",
},
listContent: {
paddingHorizontal: 8,
paddingVertical: 4,
},
bubble: {
flexDirection: "row",
alignItems: "flex-start",
backgroundColor: "#f8f8f8",
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6,
marginVertical: 2,
gap: 8,
},
bubbleText: {
flex: 1,
fontSize: 13,
color: "#333",
lineHeight: 18,
},
timestamp: {
fontSize: 11,
color: "#bbb",
marginTop: 1,
flexShrink: 0,
},
pill: {
position: "absolute",
bottom: 8,
alignSelf: "center",
backgroundColor: "#00AEEC",
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 6,
},
pillText: {
fontSize: 12,
color: "#fff",
fontWeight: "600",
},
empty: {
fontSize: 12,
color: "#999",
textAlign: "center",
paddingVertical: 20,
},
});
// ─── Live mode styles (live chat) ────────────────────────────────────────
const liveStyles = StyleSheet.create({
list: {
flex: 1,
backgroundColor: "#f9f9fb",
},
listContent: {
paddingHorizontal: 10,
paddingVertical: 6,
},
row: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
paddingVertical: 5,
},
time: {
fontSize: 10,
color: "#c0c0c0",
width: 34,
marginTop: 2,
flexShrink: 0,
},
msgBody: {
flex: 1,
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
},
guardTag: {
borderRadius: 3,
paddingHorizontal: 4,
paddingVertical: 1,
marginRight: 4,
},
guardTagText: {
color: "#fff",
fontSize: 9,
fontWeight: "700",
},
medalTag: {
flexDirection: "row",
alignItems: "center",
borderRadius: 3,
borderWidth: 1,
borderColor: "#e891ab",
overflow: "hidden",
marginRight: 4,
height: 16,
},
medalName: {
fontSize: 9,
color: "#e891ab",
paddingHorizontal: 3,
},
medalLvBox: {
paddingHorizontal: 3,
height: "100%",
justifyContent: "center",
},
medalLv: {
fontSize: 9,
color: "#fff",
fontWeight: "700",
},
uname: {
fontSize: 12,
color: "#666",
fontWeight: "500",
maxWidth: 90,
},
colon: {
fontSize: 12,
color: "#666",
},
text: {
fontSize: 13,
color: "#212121",
lineHeight: 18,
flexShrink: 1,
},
});
// ─── Gift bar styles ──────────────────────────────────────────────────────────
const giftStyles = StyleSheet.create({
bar: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#eee",
backgroundColor: "#fff",
height: 72,
},
scroll: {
paddingHorizontal: 6,
alignItems: "center",
height: "100%",
},
item: {
alignItems: "center",
justifyContent: "center",
width: 60,
paddingVertical: 6,
},
iconWrap: {
position: "relative",
alignItems: "center",
justifyContent: "center",
},
badge: {
position: "absolute",
top: -8,
alignSelf: "center",
backgroundColor: "#FF6B81",
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 1,
zIndex: 1,
minWidth: 20,
alignItems: "center",
},
badgeText: {
color: "#fff",
fontSize: 10,
fontWeight: "700",
lineHeight: 13,
},
icon: {
fontSize: 22,
lineHeight: 28,
},
name: {
fontSize: 10,
color: "#333",
fontWeight: "500",
marginTop: 2,
},
price: {
fontSize: 9,
color: "#aaa",
marginTop: 1,
},
});

View File

@@ -0,0 +1,170 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { View, Animated, StyleSheet, Text } from 'react-native';
import { DanmakuItem } from '../services/types';
import { danmakuColorToCss } from '../utils/danmaku';
interface Props {
danmakus: DanmakuItem[];
currentTime: number;
screenWidth: number;
screenHeight: number;
visible: boolean;
}
const LANE_COUNT = 5;
const LANE_H = 28;
interface ActiveDanmaku {
id: string;
item: DanmakuItem;
lane: number;
tx: Animated.Value;
opacity: Animated.Value;
}
export default function DanmakuOverlay({ danmakus, currentTime, screenWidth, screenHeight, visible }: Props) {
const [activeDanmakus, setActiveDanmakus] = useState<ActiveDanmaku[]>([]);
const laneAvailAt = useRef<number[]>(new Array(LANE_COUNT).fill(0));
const activated = useRef<Set<string>>(new Set());
const prevTimeRef = useRef<number>(currentTime);
const idCounter = useRef(0);
const mountedRef = useRef(true);
useEffect(() => {
return () => { mountedRef.current = false; };
}, []);
const pickLane = useCallback((): number | null => {
const now = Date.now();
for (let i = 0; i < LANE_COUNT; i++) {
if (laneAvailAt.current[i] <= now) return i;
}
return null;
}, []);
useEffect(() => {
activated.current.clear();
laneAvailAt.current.fill(0);
setActiveDanmakus([]);
}, [danmakus]);
useEffect(() => {
if (!visible) return;
const prevTime = prevTimeRef.current;
const didSeek = Math.abs(currentTime - prevTime) > 2;
prevTimeRef.current = currentTime;
if (didSeek) {
// Clear on seek
activated.current.clear();
laneAvailAt.current.fill(0);
setActiveDanmakus([]);
return;
}
// Find danmakus in the activation window
const window = 0.4;
const candidates = danmakus.filter(d => {
const key = `${d.time}_${d.text}`;
return d.time >= currentTime - window && d.time <= currentTime + window && !activated.current.has(key);
});
if (candidates.length === 0) return;
const newItems: ActiveDanmaku[] = [];
if (activated.current.size > 200) activated.current.clear(); // prevent memory leak
for (const item of candidates) {
const key = `${item.time}_${item.text}`;
activated.current.add(key);
if (item.mode === 1) {
// Scrolling danmaku
const lane = pickLane();
if (lane === null) continue; // drop if all lanes full
const charWidth = Math.min(item.fontSize, 22) * 0.8;
const textWidth = item.text.length * charWidth;
const duration = 8000;
// Lane becomes available when tail of this danmaku clears the right edge of screen
// tail starts at screenWidth, text has width textWidth
// tail clears left edge at duration ms
// lane available when head of next can start without overlapping: when tail clears screen right = immediately for next (head starts at screenWidth)
// conservative: lane free after textWidth / (2*screenWidth) * duration ms
const laneDelay = (textWidth / (screenWidth + textWidth)) * duration;
laneAvailAt.current[lane] = Date.now() + laneDelay;
const tx = new Animated.Value(screenWidth);
const id = `d_${idCounter.current++}`;
newItems.push({ id, item, lane, tx, opacity: new Animated.Value(1) });
Animated.timing(tx, {
toValue: -textWidth - 20,
duration,
useNativeDriver: true,
}).start(() => {
if (mountedRef.current) setActiveDanmakus(prev => prev.filter(d => d.id !== id));
});
} else {
// Fixed danmaku (mode 4 = bottom, mode 5 = top)
const opacity = new Animated.Value(1);
const id = `d_${idCounter.current++}`;
newItems.push({ id, item, lane: -1, tx: new Animated.Value(0), opacity });
Animated.sequence([
Animated.delay(2000),
Animated.timing(opacity, { toValue: 0, duration: 500, useNativeDriver: true }),
]).start(() => {
if (mountedRef.current) setActiveDanmakus(prev => prev.filter(d => d.id !== id));
});
}
}
if (newItems.length > 0) {
setActiveDanmakus(prev => {
const combined = [...prev, ...newItems];
// Cap at 30 simultaneous danmakus
return combined.slice(Math.max(0, combined.length - 30));
});
}
}, [currentTime, visible, danmakus, pickLane, screenWidth]);
if (!visible) return null;
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents="none">
{activeDanmakus.map(d => {
const fontSize = Math.min(d.item.fontSize || 25, 22);
const isScrolling = d.item.mode === 1;
const isTop = d.item.mode === 5;
return (
<Animated.Text
key={d.id}
style={{
position: 'absolute',
top: isScrolling
? 20 + d.lane * LANE_H
: isTop
? 20
: screenHeight - 48,
left: isScrolling ? 0 : undefined,
alignSelf: !isScrolling ? 'center' : undefined,
transform: isScrolling ? [{ translateX: d.tx }] : [],
opacity: d.opacity,
color: danmakuColorToCss(d.item.color),
fontSize,
fontWeight: '700',
textShadowColor: 'rgba(0,0,0,0.8)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
}}
>
{d.item.text}
</Animated.Text>
);
})}
</View>
);
}

View File

@@ -0,0 +1,162 @@
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useDownloadStore } from '../store/downloadStore';
import { useTheme } from '../utils/theme';
const SIZE = 32; // 环外径
const RING = 3; // 环宽
const BLUE = '#00AEEC';
const INNER = SIZE - RING * 2;
interface Props {
onPress: () => void;
}
export function DownloadProgressBtn({ onPress }: Props) {
const theme = useTheme();
const tasks = useDownloadStore((s) => s.tasks);
const downloading = Object.values(tasks).filter((t) => t.status === 'downloading');
const hasDownloading = downloading.length > 0;
const avgProgress = hasDownloading
? downloading.reduce((sum, t) => sum + t.progress, 0) / downloading.length
: 0;
return (
<TouchableOpacity onPress={onPress} style={styles.btn} activeOpacity={0.7}>
{/* 进度环,绝对定位居中 */}
{/* {hasDownloading && (
<View style={styles.ringWrap} pointerEvents="none">
<RingProgress progress={avgProgress} />
</View>
)} */}
<Ionicons
name="cloud-download-outline"
size={22}
color={hasDownloading ? BLUE : theme.iconDefault}
/>
</TouchableOpacity>
);
}
/**
* 双半圆裁剪法绘制圆弧进度
*
* 两个「D 形」(实心半圆) 分别放在左/右裁剪容器中,
* 旋转 D 形就能在容器内扫出弧形,配合白色内圆变成环形。
*
* 旋转轴 = D 形 View 的默认中心 = 外圆圆心,无需 transformOrigin。
*/
function RingProgress({ progress }: { progress: number }) {
const p = Math.max(0, Math.min(1, progress));
// 右半弧:进度 0→50%,右 D 从 -180°→0°顺时针
const rightAngle = -180 + Math.min(p * 2, 1) * 180;
// 左半弧:进度 50%→100%,左 D 从 180°→0°
const leftAngle = 180 - Math.max(0, p * 2 - 1) * 180;
return (
<View style={styles.ring}>
{/* 灰色背景环 */}
<View style={styles.ringBg} />
{/* ── 右裁剪:左边缘 = 圆心,只露右半 ── */}
<View style={styles.rightClip}>
{/* left: -SIZE/2 → D 形中心落在容器左边缘 = 外圆圆心 */}
<View style={[styles.dRight, { transform: [{ rotate: `${rightAngle}deg` }] }]} />
</View>
{/* ── 左裁剪:右边缘 = 圆心,只露左半 ── */}
<View style={styles.leftClip}>
{/* left: 0 → D 形中心在容器右边缘 = 外圆圆心 */}
<View style={[styles.dLeft, { transform: [{ rotate: `${leftAngle}deg` }] }]} />
</View>
{/* 白色内圆,把实心扇区变成环形 */}
<View style={styles.inner} />
</View>
);
}
const styles = StyleSheet.create({
btn: {
width: SIZE + 8,
height: SIZE + 8,
alignItems: 'center',
justifyContent: 'center',
},
// 绝对定位居中overflow:hidden 防止 D 形溢出
ringWrap: {
position: 'absolute',
top: 4,
left: 4,
width: SIZE,
height: SIZE,
overflow: 'hidden',
},
ring: {
width: SIZE,
height: SIZE,
alignItems: 'center',
justifyContent: 'center',
},
ringBg: {
position: 'absolute',
width: SIZE,
height: SIZE,
borderRadius: SIZE / 2,
borderWidth: RING,
borderColor: '#e0e0e0',
},
// 右裁剪容器left = SIZE/2圆心处宽 SIZE/2只露右半
rightClip: {
position: 'absolute',
left: SIZE / 2,
top: 0,
width: SIZE / 2,
height: SIZE,
overflow: 'hidden',
},
// 右 D 形右半圆left = -SIZE/2 → center = (0, SIZE/2) in clip = 外圆圆心
dRight: {
position: 'absolute',
left: -SIZE / 2,
top: 0,
width: SIZE,
height: SIZE,
borderTopRightRadius: SIZE / 2,
borderBottomRightRadius: SIZE / 2,
backgroundColor: BLUE,
},
// 左裁剪容器right = SIZE/2圆心处宽 SIZE/2只露左半
leftClip: {
position: 'absolute',
left: 0,
top: 0,
width: SIZE / 2,
height: SIZE,
overflow: 'hidden',
},
// 左 D 形左半圆left = 0 → center = (SIZE/2, SIZE/2) in clip = 外圆圆心
dLeft: {
position: 'absolute',
left: 0,
top: 0,
width: SIZE,
height: SIZE,
borderTopLeftRadius: SIZE / 2,
borderBottomLeftRadius: SIZE / 2,
backgroundColor: BLUE,
},
// 白色内圆(遮住 D 形中心,留出环宽)
inner: {
width: INNER,
height: INNER,
borderRadius: INNER / 2,
backgroundColor: '#fff',
},
});

View File

@@ -0,0 +1,202 @@
import React, { useEffect, useRef } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
Animated,
StyleSheet,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useDownload } from "../hooks/useDownload";
import { useTheme } from "../utils/theme";
interface Props {
visible: boolean;
onClose: () => void;
bvid: string;
cid: number;
title: string;
cover: string;
qualities: { qn: number; desc: string }[];
}
export function DownloadSheet({
visible,
onClose,
bvid,
cid,
title,
cover,
qualities,
}: Props) {
const { tasks, startDownload, taskKey } = useDownload();
const theme = useTheme();
const slideAnim = useRef(new Animated.Value(300)).current;
useEffect(() => {
Animated.timing(slideAnim, {
toValue: visible ? 0 : 300,
duration: 260,
useNativeDriver: true,
}).start();
}, [visible]);
if (qualities.length === 0) return null;
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
onPress={onClose}
/>
<Animated.View
style={[styles.sheet, { backgroundColor: theme.sheetBg, transform: [{ translateY: slideAnim }] }]}
>
<View style={styles.header}>
<Text style={[styles.headerTitle, { color: theme.modalText }]}></Text>
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
<Ionicons name="close" size={20} color={theme.modalTextSub} />
</TouchableOpacity>
</View>
<View style={[styles.divider, { backgroundColor: theme.modalBorder }]} />
{qualities.map((q) => {
const key = taskKey(bvid, q.qn);
const task = tasks[key];
return (
<View key={q.qn} style={styles.row}>
<Text style={[styles.qualityLabel, { color: theme.modalText }]}>{q.desc}</Text>
<View style={styles.right}>
{!task && (
<TouchableOpacity
style={styles.downloadBtn}
onPress={() =>
startDownload(bvid, cid, q.qn, q.desc, title, cover)
}
>
<Text style={styles.downloadBtnTxt}></Text>
</TouchableOpacity>
)}
{task?.status === "downloading" && (
<View style={styles.progressWrap}>
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{
width: `${Math.round(task.progress * 100)}%` as any,
},
]}
/>
</View>
<Text style={styles.progressTxt}>
{Math.round(task.progress * 100)}%
</Text>
</View>
)}
{task?.status === "done" && (
<View style={styles.doneRow}>
<Ionicons
name="checkmark-circle"
size={16}
color="#00AEEC"
/>
<Text style={styles.doneTxt}></Text>
</View>
)}
{task?.status === "error" && (
<View style={styles.errorWrap}>
{!!task.error && (
<Text style={styles.errorMsg} numberOfLines={2}>
{task.error}
</Text>
)}
<TouchableOpacity
style={styles.retryBtn}
onPress={() =>
startDownload(bvid, cid, q.qn, q.desc, title, cover)
}
>
<Ionicons name="refresh" size={14} color="#f44" />
<Text style={styles.retryTxt}></Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
})}
<View style={styles.footer} />
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.4)",
},
sheet: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "#fff",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: 20,
paddingTop: 16,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 12,
},
headerTitle: { fontSize: 16, fontWeight: "700", color: "#212121" },
closeBtn: { padding: 4 },
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: "#eee",
marginBottom: 4,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 14,
},
qualityLabel: { fontSize: 15, color: "#212121" },
right: { flexDirection: "row", alignItems: "center" },
downloadBtn: {
backgroundColor: "#00AEEC",
paddingHorizontal: 16,
paddingVertical: 5,
borderRadius: 14,
},
downloadBtnTxt: { color: "#fff", fontSize: 13, fontWeight: "600" },
progressWrap: { flexDirection: "row", alignItems: "center", gap: 8 },
progressTrack: {
width: 80,
height: 4,
borderRadius: 2,
backgroundColor: "#e0e0e0",
overflow: "hidden",
},
progressFill: { height: 4, backgroundColor: "#00AEEC", borderRadius: 2 },
progressTxt: { fontSize: 12, color: "#666", minWidth: 32 },
doneRow: { flexDirection: "row", alignItems: "center", gap: 4 },
doneTxt: { fontSize: 13, color: "#00AEEC" },
errorWrap: { alignItems: "flex-end", gap: 2 },
errorMsg: { fontSize: 11, color: "#f44", maxWidth: 160, textAlign: "right" },
retryBtn: { flexDirection: "row", alignItems: "center", gap: 4 },
retryTxt: { fontSize: 13, color: "#f44" },
footer: { height: 24 },
});

View File

@@ -0,0 +1,110 @@
import React, { useEffect, useState } from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
Image,
StyleSheet,
} from "react-native";
import { useRouter } from "expo-router";
import { useAuthStore } from "../store/authStore";
import { getFollowedLiveRooms } from "../services/api";
import { LivePulse } from "./LivePulse";
import { proxyImageUrl } from "../utils/imageUrl";
import { useTheme } from "../utils/theme";
import type { LiveRoom } from "../services/types";
export function FollowedLiveStrip() {
const { sessdata } = useAuthStore();
const [rooms, setRooms] = useState<LiveRoom[]>([]);
const router = useRouter();
const theme = useTheme();
useEffect(() => {
if (!sessdata) return;
getFollowedLiveRooms()
.then(setRooms)
.catch(() => {});
}, [sessdata]);
if (!sessdata || rooms.length === 0) return null;
return (
<View style={[styles.container, { backgroundColor: theme.bg }]}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{rooms.map((room, index) => (
<TouchableOpacity
key={`followed-${room.roomid ?? index}`}
style={styles.item}
onPress={() => router.push(`/live/${room.roomid}` as any)}
activeOpacity={0.7}
>
<View style={styles.pulseRow}>
<LivePulse />
<Text style={{ color: "#fff", fontSize: 9, marginLeft: 2 }}>
</Text>
</View>
<Image
source={{ uri: proxyImageUrl(room.face) }}
style={[styles.avatar, { backgroundColor: theme.card }]}
/>
<Text
style={[styles.name, { color: theme.text }]}
numberOfLines={1}
>
{room.uname.length > 5 ? room.uname.slice(0, 5) : room.uname}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: "#f4f4f4",
paddingHorizontal: 4,
paddingVertical: 8,
},
scrollContent: {
gap: 12,
alignItems: "center",
},
item: {
alignItems: "center",
width: 56,
position: "relative",
},
pulseRow: {
position: "absolute",
backgroundColor: "rgba(0,0,0,0.6)",
bottom: 18,
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 10,
zIndex: 100,
display: "flex",
flexDirection: "row",
alignItems: "center",
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "#eee",
},
name: {
fontSize: 11,
color: "#333",
marginTop: 4,
textAlign: "center",
width: 56,
},
});

View File

@@ -0,0 +1,173 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Modal,
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
ActivityIndicator,
Animated,
ScrollView,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { DownloadTask } from '../store/downloadStore';
import { startLanServer, stopLanServer, buildVideoUrl } from '../utils/lanServer';
interface Props {
visible: boolean;
task: DownloadTask | null;
onClose: () => void;
}
export function LanShareModal({ visible, task, onClose }: Props) {
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [qrImageLoaded, setQrImageLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const slideY = useRef(new Animated.Value(400)).current;
useEffect(() => {
if (visible) {
Animated.spring(slideY, { toValue: 0, useNativeDriver: true, bounciness: 4 }).start();
} else {
slideY.setValue(400);
}
}, [visible]);
useEffect(() => {
if (!visible || !task) return;
setVideoUrl(null);
setQrImageLoaded(false);
setLoading(true);
startLanServer()
.then((baseUrl) => {
setVideoUrl(buildVideoUrl(baseUrl, task.bvid, task.qn));
})
.catch(() => setVideoUrl(null))
.finally(() => setLoading(false));
return () => {
stopLanServer();
};
}, [visible, task?.bvid, task?.qn]);
async function handleCopy() {
if (!videoUrl) return;
await Clipboard.setStringAsync(videoUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function handleClose() {
stopLanServer();
onClose();
}
const qrSrc = videoUrl
? `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(videoUrl)}&size=400x400`
: null;
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={handleClose}>
<View style={styles.overlay} pointerEvents="box-none" />
<Animated.View style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}>
<View style={styles.sheet}>
<Text style={styles.title}></Text>
{task && (
<Text style={styles.taskTitle} numberOfLines={2}>
{task.title} · {task.qdesc}
</Text>
)}
{loading ? (
<ActivityIndicator size="large" color="#00AEEC" style={styles.loader} />
) : qrSrc ? (
<>
<View style={styles.qrWrapper}>
<Image
source={{ uri: qrSrc }}
style={styles.qr}
onLoad={() => setQrImageLoaded(true)}
/>
{!qrImageLoaded && (
<View style={styles.qrLoader}>
<ActivityIndicator size="large" color="#00AEEC" />
</View>
)}
</View>
<TouchableOpacity style={styles.urlRow} onPress={handleCopy} activeOpacity={0.7}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.urlScroll}>
<Text style={styles.urlText}>{videoUrl}</Text>
</ScrollView>
<Ionicons
name={copied ? 'checkmark-circle' : 'copy-outline'}
size={20}
color={copied ? '#4caf50' : '#00AEEC'}
style={{ marginLeft: 8 }}
/>
</TouchableOpacity>
<Text style={styles.hint}> WiFi </Text>
</>
) : (
<Text style={styles.hint}> WiFi12312</Text>
)}
<TouchableOpacity style={styles.closeBtn} onPress={handleClose}>
<Text style={styles.closeTxt}></Text>
</TouchableOpacity>
</View>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetWrapper: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: 24,
alignItems: 'center',
},
title: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
taskTitle: { fontSize: 13, color: '#666', marginBottom: 16, textAlign: 'center' },
loader: { marginVertical: 40 },
qrWrapper: { width: 200, height: 200, marginBottom: 16 },
qr: { width: 200, height: 200 },
qrLoader: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f4f4f4',
},
urlRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f4f4f4',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
marginBottom: 12,
maxWidth: '100%',
},
urlScroll: { maxWidth: 260 },
urlText: { fontSize: 12, color: '#333', fontFamily: 'monospace' },
hint: { fontSize: 12, color: '#999', marginBottom: 20, textAlign: 'center' },
closeBtn: { padding: 12 },
closeTxt: { fontSize: 14, color: '#00AEEC' },
});

148
components/LiveCard.tsx Normal file
View File

@@ -0,0 +1,148 @@
import React from "react";
import {
View,
Text,
Image,
TouchableOpacity,
StyleSheet,
Dimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LivePulse } from "./LivePulse";
import type { LiveRoom } from "../services/types";
import { formatCount } from "../utils/format";
import { proxyImageUrl } from "../utils/imageUrl";
import { useTheme } from "../utils/theme";
const { width } = Dimensions.get("window");
const CARD_WIDTH = (width - 14) / 2;
interface Props {
item: LiveRoom;
isLivePulse?: Boolean;
onPress?: () => void;
fullWidth?: boolean;
}
export const LiveCard = React.memo(function LiveCard({
item,
onPress,
fullWidth,
isLivePulse = false,
}: Props) {
const cardWidth = fullWidth ? width - 8 : CARD_WIDTH;
const theme = useTheme();
return (
<TouchableOpacity
style={[styles.card, { width: cardWidth, backgroundColor: theme.card }]}
onPress={onPress}
activeOpacity={0.85}
>
<View style={styles.thumbContainer}>
<Image
source={{ uri: proxyImageUrl(item.cover) }}
style={[
styles.thumb,
{ width: cardWidth, height: cardWidth * 0.5625, backgroundColor: theme.card },
]}
resizeMode="cover"
/>
<View style={styles.liveBadge}>
{isLivePulse && <LivePulse />}
<Text style={styles.liveBadgeText}></Text>
</View>
<View style={styles.meta}>
<Ionicons name="people" size={11} color="#fff" />
<Text style={styles.metaText}>{formatCount(item.online)}</Text>
</View>
<View style={styles.areaBadge}>
<Text style={styles.areaText}>{item.area_name}</Text>
</View>
</View>
<View style={styles.info}>
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{item.title}
</Text>
<View style={styles.ownerRow}>
<Image
source={{ uri: proxyImageUrl(item.face) }}
style={styles.avatar}
/>
<Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
{item.uname}
</Text>
</View>
</View>
</TouchableOpacity>
);
});
const styles = StyleSheet.create({
card: {
width: CARD_WIDTH,
marginBottom: 6,
backgroundColor: "#fff",
borderRadius: 6,
overflow: "hidden",
},
thumbContainer: { position: "relative" },
thumb: {
width: CARD_WIDTH,
height: CARD_WIDTH * 0.5625,
backgroundColor: "#ddd",
},
liveBadge: {
position: "absolute",
top: 4,
left: 4,
backgroundColor: "rgba(0,0,0,0.6)",
borderRadius: 5,
paddingHorizontal: 5,
paddingVertical: 1,
flexDirection: "row",
alignItems: "center",
gap: 2,
},
liveBadgeText: { color: "#fff", fontSize: 10, fontWeight: "400" },
meta: {
position: "absolute",
bottom: 4,
left: 4,
paddingHorizontal: 4,
borderRadius: 5,
backgroundColor: "rgba(0,0,0,0.6)",
flexDirection: "row",
alignItems: "center",
gap: 2,
},
metaText: { fontSize: 10, color: "#fff" },
areaBadge: {
position: "absolute",
bottom: 4,
right: 4,
borderRadius: 5,
paddingHorizontal: 4,
backgroundColor: "rgba(0,0,0,0.6)",
},
areaText: { color: "#fff", fontSize: 10 },
info: { padding: 6 },
title: {
fontSize: 12,
color: "#212121",
height: 33,
marginBottom: 4,
},
ownerRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
marginTop: 2,
},
avatar: {
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: "#eee",
},
owner: { fontSize: 11, color: "#999", flex: 1 },
});

View File

@@ -0,0 +1,202 @@
import React, { useRef } from 'react';
import {
View,
Text,
Image,
StyleSheet,
Animated,
PanResponder,
Dimensions,
Platform,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useLiveStore } from '../store/liveStore';
import { useVideoStore } from '../store/videoStore';
import { proxyImageUrl } from '../utils/imageUrl';
const MINI_W = 160;
const MINI_H = 90;
const LIVE_HEADERS = {};
function snapRelease(
pan: Animated.ValueXY,
curX: number,
curY: number,
sw: number,
sh: number,
) {
const snapRight = 0;
const snapLeft = -(sw - MINI_W - 24);
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
Animated.spring(pan, {
toValue: { x: snapX, y: clampedY },
useNativeDriver: false,
tension: 120,
friction: 10,
}).start();
}
export function LiveMiniPlayer() {
const { isActive, roomId, title, cover, hlsUrl, clearLive } = useLiveStore();
const videoMiniActive = useVideoStore(s => s.isActive);
const router = useRouter();
const insets = useSafeAreaInsets();
const pan = useRef(new Animated.ValueXY()).current;
const isDragging = useRef(false);
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
const storeRef = useRef({ roomId, clearLive, router });
storeRef.current = { roomId, clearLive, router };
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
isDragging.current = false;
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
pan.setValue({ x: 0, y: 0 });
},
onPanResponderMove: (_, gs) => {
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
isDragging.current = true;
}
pan.x.setValue(gs.dx);
pan.y.setValue(gs.dy);
},
onPanResponderRelease: (evt) => {
pan.flattenOffset();
if (!isDragging.current) {
const { locationX, locationY } = evt.nativeEvent;
const { roomId: rid, clearLive: clear, router: r } = storeRef.current;
if (locationX > MINI_W - 28 && locationY < 28) {
clear();
} else {
r.push(`/live/${rid}` as any);
}
return;
}
const { width: sw, height: sh } = Dimensions.get('window');
snapRelease(pan, (pan.x as any)._value, (pan.y as any)._value, sw, sh);
},
onPanResponderTerminate: () => { pan.flattenOffset(); },
}),
).current;
if (!isActive) return null;
const bottomOffset = insets.bottom + 16 + (videoMiniActive ? 106 : 0);
// Web 端降级:封面图 + LIVE 徽标
if (Platform.OS === 'web') {
return (
<Animated.View
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
{...panResponder.panHandlers}
>
<Image source={{ uri: proxyImageUrl(cover) }} style={styles.videoArea} />
<View style={styles.liveBadge} pointerEvents="none">
<View style={styles.liveDot} />
<Text style={styles.liveText}>LIVE</Text>
</View>
<Text style={styles.titleText} numberOfLines={1}>{title}</Text>
<View style={styles.closeBtn}>
<Ionicons name="close" size={14} color="#fff" />
</View>
</Animated.View>
);
}
// Native实际 HLS 流播放
const Video = require('react-native-video').default;
return (
<Animated.View
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
{...panResponder.panHandlers}
>
{/* pointerEvents="none" 防止 Video 原生层吞噬触摸事件 */}
<View style={styles.videoArea} pointerEvents="none">
<Video
key={hlsUrl}
source={{ uri: hlsUrl, headers: LIVE_HEADERS }}
style={StyleSheet.absoluteFill}
resizeMode="cover"
controls={false}
muted={false}
paused={false}
repeat={false}
onError={clearLive}
/>
</View>
<View style={styles.liveBadge} pointerEvents="none">
<View style={styles.liveDot} />
<Text style={styles.liveText}>LIVE</Text>
</View>
<Text style={styles.titleText} numberOfLines={1}>{title}</Text>
{/* 关闭按钮视觉层,点击逻辑由 onPanResponderRelease 坐标判断 */}
<View style={styles.closeBtn}>
<Ionicons name="close" size={14} color="#fff" />
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
right: 12,
width: MINI_W,
height: MINI_H,
borderRadius: 8,
backgroundColor: '#1a1a1a',
overflow: 'hidden',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
videoArea: {
width: '100%',
height: 66,
backgroundColor: '#111',
},
liveBadge: {
position: 'absolute',
top: 4,
left: 6,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.55)',
paddingHorizontal: 5,
paddingVertical: 2,
borderRadius: 3,
gap: 3,
},
liveDot: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: '#f00' },
liveText: { color: '#fff', fontSize: 9, fontWeight: '700', letterSpacing: 0.5 },
titleText: {
color: '#fff',
fontSize: 11,
paddingHorizontal: 6,
paddingVertical: 3,
lineHeight: 14,
height: 24,
backgroundColor: '#1a1a1a',
},
closeBtn: {
position: 'absolute',
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'center',
justifyContent: 'center',
},
});

416
components/LivePlayer.tsx Normal file
View File

@@ -0,0 +1,416 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
Modal,
Platform,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useTheme } from "../utils/theme";
interface Props {
hlsUrl: string;
flvUrl?: string;
isLive: boolean;
qualities?: { qn: number; desc: string }[];
currentQn?: number;
onQualityChange?: (qn: number) => void;
}
const HIDE_DELAY = 3000;
const HEADERS = {};
export function LivePlayer({
hlsUrl,
flvUrl,
isLive,
qualities = [],
currentQn = 0,
onQualityChange,
}: Props) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625;
if (Platform.OS === "web") {
return (
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }]}>
<Text style={styles.webHint}></Text>
</View>
);
}
if (!isLive || !hlsUrl) {
return (
<View style={[styles.container, { width: SCREEN_W, height: VIDEO_H }]}>
<Ionicons name="tv-outline" size={40} color="#555" />
<Text style={styles.offlineText}></Text>
</View>
);
}
return (
<NativeLivePlayer
hlsUrl={hlsUrl}
screenW={SCREEN_W}
screenH={SCREEN_H}
videoH={VIDEO_H}
qualities={qualities}
currentQn={currentQn}
onQualityChange={onQualityChange}
/>
);
}
function NativeLivePlayer({
hlsUrl,
screenW,
screenH,
videoH,
qualities,
currentQn,
onQualityChange,
}: {
hlsUrl: string;
screenW: number;
screenH: number;
videoH: number;
qualities: { qn: number; desc: string }[];
currentQn: number;
onQualityChange?: (qn: number) => void;
}) {
const Video = require("react-native-video").default;
const theme = useTheme();
const [showControls, setShowControls] = useState(true);
const [paused, setPaused] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [buffering, setBuffering] = useState(true);
const [showQualityPanel, setShowQualityPanel] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const videoRef = useRef<any>(null);
const currentTimeRef = useRef(0);
const resetHideTimer = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => setShowControls(false), HIDE_DELAY);
}, []);
useEffect(() => {
resetHideTimer();
return () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
};
}, []);
// Lock/unlock orientation on fullscreen toggle
useEffect(() => {
(async () => {
try {
const ScreenOrientation = require("expo-screen-orientation");
if (isFullscreen) {
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
);
} else {
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
} catch {
/* graceful degradation in Expo Go */
}
})();
}, [isFullscreen]);
// Restore portrait on unmount
useEffect(() => {
return () => {
(async () => {
try {
const ScreenOrientation = require("expo-screen-orientation");
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
} catch {
/* ignore */
}
})();
};
}, []);
const handleTap = useCallback(() => {
setShowControls((prev) => {
if (!prev) {
resetHideTimer();
return true;
}
if (hideTimer.current) clearTimeout(hideTimer.current);
return false;
});
}, [resetHideTimer]);
const fsW = Math.max(screenW, screenH);
const fsH = Math.min(screenW, screenH);
const containerStyle = isFullscreen
? { position: 'absolute' as const, top: 0, left: 0, width: fsW, height: fsH, zIndex: 999, elevation: 999 }
: { width: screenW, height: videoH };
const currentQnDesc = qualities.find((q) => q.qn === currentQn)?.desc ?? "";
const videoContent = (
<View style={[styles.container, containerStyle]}>
<Video
key={hlsUrl}
ref={videoRef}
source={{ uri: hlsUrl, headers: HEADERS }}
style={StyleSheet.absoluteFill}
resizeMode="contain"
controls={false}
paused={paused}
onProgress={({ currentTime: ct }: { currentTime: number }) => {
currentTimeRef.current = ct;
}}
onBuffer={({ isBuffering }: { isBuffering: boolean }) =>
setBuffering(isBuffering)
}
onLoad={() => {
setBuffering(false);
if (currentTimeRef.current > 0) {
videoRef.current?.seek(currentTimeRef.current);
}
}}
/>
{buffering && (
<View style={styles.bufferingOverlay} pointerEvents="none">
<Text style={styles.bufferingText}>...</Text>
</View>
)}
<TouchableWithoutFeedback onPress={handleTap}>
<View style={StyleSheet.absoluteFill} />
</TouchableWithoutFeedback>
{showControls && (
<>
{/* Center play/pause */}
<TouchableOpacity
style={styles.centerBtn}
onPress={() => {
setPaused((p) => !p);
resetHideTimer();
}}
>
<View style={styles.centerBtnBg}>
<Ionicons
name={paused ? "play" : "pause"}
size={28}
color="#fff"
/>
</View>
</TouchableOpacity>
{/* Bottom controls */}
<View style={styles.bottomBar}>
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => {
setPaused((p) => !p);
resetHideTimer();
}}
>
<Ionicons
name={paused ? "play" : "pause"}
size={16}
color="#fff"
/>
</TouchableOpacity>
<View style={{ flex: 1 }} />
{qualities.length > 0 && (
<TouchableOpacity
style={styles.qualityBtn}
onPress={() => {
setShowQualityPanel(true);
resetHideTimer();
}}
>
<Text style={styles.qualityText}>
{currentQnDesc || "清晰度"}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => {
if (isFullscreen) setPaused(true);
setIsFullscreen((fs) => !fs);
resetHideTimer();
}}
>
<Ionicons
name={isFullscreen ? "contract" : "expand"}
size={16}
color="#fff"
/>
</TouchableOpacity>
</View>
</>
)}
{/* Quality selector panel */}
<Modal
visible={showQualityPanel}
transparent
animationType="fade"
onRequestClose={() => setShowQualityPanel(false)}
>
<TouchableOpacity
style={styles.qualityOverlay}
activeOpacity={1}
onPress={() => setShowQualityPanel(false)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<View style={[styles.qualityPanel, { backgroundColor: theme.modalBg }]}>
<Text style={[styles.qualityPanelTitle, { color: theme.modalText }]}></Text>
{qualities.map((q) => (
<TouchableOpacity
key={q.qn}
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
onPress={() => {
onQualityChange?.(q.qn);
setShowQualityPanel(false);
}}
>
<Text
style={[
styles.qualityItemText,
{ color: theme.modalTextSub },
currentQn === q.qn && styles.qualityItemTextActive,
]}
>
{q.desc}
</Text>
{currentQn === q.qn && (
<Ionicons name="checkmark" size={14} color="#00AEEC" />
)}
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</View>
);
return videoContent;
}
const styles = StyleSheet.create({
container: {
backgroundColor: "#000",
alignItems: "center",
justifyContent: "center",
},
webHint: { color: "#fff", fontSize: 15 },
offlineText: { color: "#999", fontSize: 14, marginTop: 10 },
bufferingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
},
bufferingText: { color: "#fff", fontSize: 13, opacity: 0.8 },
liveBadge: {
position: "absolute",
top: 10,
left: 12,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.5)",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 4,
},
liveDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: "#f00",
marginRight: 5,
},
liveText: {
color: "#fff",
fontSize: 11,
fontWeight: "700",
letterSpacing: 1,
},
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",
},
bottomBar: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
paddingBottom: 8,
paddingTop: 24,
backgroundColor: "rgba(0,0,0,0)",
},
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
qualityBtn: {
paddingHorizontal: 8,
paddingVertical: 4,
backgroundColor: "rgba(0,0,0,0.4)",
borderRadius: 4,
marginRight: 4,
},
qualityText: { color: "#fff", fontSize: 11, fontWeight: "600" },
qualityOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "center",
alignItems: "center",
},
qualityPanel: {
backgroundColor: "#fff",
borderRadius: 12,
paddingVertical: 8,
paddingHorizontal: 16,
minWidth: 180,
},
qualityPanelTitle: {
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" },
qualityItemTextActive: { color: "#00AEEC", fontWeight: "700" },
});

64
components/LivePulse.tsx Normal file
View File

@@ -0,0 +1,64 @@
import React, { useEffect, useRef } from "react";
import { View, StyleSheet, Animated } from "react-native";
const BAR_COUNT = 3;
const BAR_HEIGHT = 8;
export function LivePulse() {
const anims = useRef(
Array.from({ length: BAR_COUNT }, () => new Animated.Value(0.3)),
).current;
useEffect(() => {
const animations = anims.map((anim, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 120),
Animated.timing(anim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(anim, {
toValue: 0.3,
duration: 300,
useNativeDriver: true,
}),
]),
),
);
animations.forEach((a) => a.start());
return () => animations.forEach((a) => a.stop());
}, []);
return (
<View style={pulseStyles.container}>
{anims.map((anim, i) => (
<Animated.View
key={i}
style={[
pulseStyles.bar,
{
transform: [{ scaleY: anim }],
},
]}
/>
))}
</View>
);
}
export const pulseStyles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "flex-end",
height: BAR_HEIGHT,
gap: 1,
},
bar: {
width: 2,
height: BAR_HEIGHT,
backgroundColor: "#fff",
borderRadius: 1,
},
});

116
components/LoginModal.tsx Normal file
View File

@@ -0,0 +1,116 @@
import React, { useRef } from "react";
import {
Modal,
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { pollQRCode } from "../services/api";
import { useAuthStore } from "../store/authStore";
import { useTheme } from "../utils/theme";
interface Props {
visible: boolean;
onClose: () => void;
}
export function LoginModal({ visible, onClose }: Props) {
const login = useAuthStore((s) => s.login);
const theme = useTheme();
const slideY = useRef(new Animated.Value(300)).current;
React.useEffect(() => {
if (visible) {
Animated.spring(slideY, {
toValue: 0,
useNativeDriver: true,
bounciness: 4,
}).start();
} else {
slideY.setValue(300);
}
}, [visible]);
async function handleLogin() {
const result = await pollQRCode('mock-key');
if (result.code === 0 && result.cookie) {
await login(result.cookie, '', '');
onClose();
}
}
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
>
<View style={styles.overlay} pointerEvents="box-none" />
<Animated.View
style={[styles.sheetWrapper, { transform: [{ translateY: slideY }] }]}
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<Text style={[styles.title, { color: theme.modalText }]}></Text>
<View style={styles.iconWrap}>
<Ionicons name="person-circle-outline" size={72} color="#00AEEC" />
</View>
<Text style={[styles.hint, { color: theme.modalTextSub }]}>
</Text>
<TouchableOpacity
style={styles.loginBtn}
onPress={handleLogin}
activeOpacity={0.85}
>
<Text style={styles.loginBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
<Text style={[styles.closeTxt, { color: "#00AEEC" }]}></Text>
</TouchableOpacity>
</View>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)",
},
sheetWrapper: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
},
sheet: {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
padding: 24,
alignItems: "center",
},
title: { fontSize: 18, fontWeight: "600", marginBottom: 20 },
iconWrap: { marginBottom: 16 },
hint: { fontSize: 13, marginBottom: 24, textAlign: "center" },
loginBtn: {
backgroundColor: "#00AEEC",
borderRadius: 24,
paddingVertical: 12,
paddingHorizontal: 48,
marginBottom: 8,
},
loginBtnText: { color: "#fff", fontSize: 16, fontWeight: "600" },
closeBtn: { padding: 12 },
closeTxt: { fontSize: 14 },
});

124
components/MiniPlayer.tsx Normal file
View File

@@ -0,0 +1,124 @@
import React, { useRef } from 'react';
import {
View, Text, Image, StyleSheet,
Animated, PanResponder, Dimensions,
} from 'react-native';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useVideoStore } from '../store/videoStore';
import { proxyImageUrl } from '../utils/imageUrl';
const MINI_W = 160;
const MINI_H = 90;
export function MiniPlayer() {
const { isActive, bvid, title, cover, clearVideo } = useVideoStore();
const router = useRouter();
const insets = useSafeAreaInsets();
const pan = useRef(new Animated.ValueXY()).current;
const isDragging = useRef(false);
// 用 ref 保持最新值,避免 PanResponder 闭包捕获过期的初始值
const storeRef = useRef({ bvid, clearVideo, router });
storeRef.current = { bvid, clearVideo, router };
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
isDragging.current = false;
pan.setOffset({ x: (pan.x as any)._value, y: (pan.y as any)._value });
pan.setValue({ x: 0, y: 0 });
},
onPanResponderMove: (_, gs) => {
if (Math.abs(gs.dx) > 5 || Math.abs(gs.dy) > 5) {
isDragging.current = true;
}
pan.x.setValue(gs.dx);
pan.y.setValue(gs.dy);
},
onPanResponderRelease: (evt) => {
pan.flattenOffset();
if (!isDragging.current) {
const { locationX, locationY } = evt.nativeEvent;
const { bvid: vid, clearVideo: clear, router: r } = storeRef.current;
if (locationX > MINI_W - 28 && locationY < 28) {
clear();
} else {
r.push(`/video/${vid}` as any);
}
return;
}
const { width: sw, height: sh } = Dimensions.get('window');
const curX = (pan.x as any)._value;
const curY = (pan.y as any)._value;
const snapRight = 0;
const snapLeft = -(sw - MINI_W - 24);
const snapX = curX < snapLeft / 2 ? snapLeft : snapRight;
const clampedY = Math.max(-sh + MINI_H + 60, Math.min(60, curY));
Animated.spring(pan, {
toValue: { x: snapX, y: clampedY },
useNativeDriver: false,
tension: 120,
friction: 10,
}).start();
},
onPanResponderTerminate: () => { pan.flattenOffset(); },
})
).current;
if (!isActive) return null;
const bottomOffset = insets.bottom + 16;
return (
<Animated.View
style={[styles.container, { bottom: bottomOffset, transform: pan.getTranslateTransform() }]}
{...panResponder.panHandlers}
>
<Image source={{ uri: proxyImageUrl(cover) }} style={styles.cover} />
<Text style={styles.title} numberOfLines={1}>{title}</Text>
{/* 关闭按钮仅作视觉展示,点击逻辑由 onPanResponderRelease 坐标判断处理 */}
<View style={styles.closeBtn}>
<Ionicons name="close" size={14} color="#fff" />
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
right: 12,
width: MINI_W,
height: MINI_H,
borderRadius: 8,
backgroundColor: '#1a1a1a',
overflow: 'hidden',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
cover: { width: '100%', height: 64, backgroundColor: '#333' },
title: {
color: '#fff',
fontSize: 11,
paddingHorizontal: 6,
paddingVertical: 4,
lineHeight: 14,
},
closeBtn: {
position: 'absolute',
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@@ -0,0 +1,731 @@
import React, {
useState,
useRef,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import { formatDuration } from "../utils/format";
import {
View,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
Text,
Modal,
Image,
PanResponder,
useWindowDimensions,
} 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,
DanmakuItem,
} from "../services/types";
import { buildDashMpdUri } from "../utils/dash";
import { getVideoShot } from "../services/api";
import DanmakuOverlay from "./DanmakuOverlay";
import { useTheme } from "../utils/theme";
const BAR_H = 3;
// 进度球尺寸
const BALL = 12;
// 活跃状态下的拖动球增大尺寸,提升触控体验
const BALL_ACTIVE = 16;
const HIDE_DELAY = 3000;
const HEADERS = {};
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
//
function findFrameByTime(index: number[], seekTime: number): number {
let lo = 0,
hi = index.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (index[mid] <= seekTime) lo = mid;
else hi = mid - 1;
}
return lo;
}
export interface NativeVideoPlayerRef {
seek: (t: number) => void;
setPaused: (v: boolean) => void;
}
interface Props {
playData: PlayUrlResponse | null;
qualities: { qn: number; desc: string }[];
currentQn: number;
onQualityChange: (qn: number) => void;
onFullscreen: () => void;
style?: object;
bvid?: string;
cid?: number;
danmakus?: DanmakuItem[];
isFullscreen?: boolean;
onTimeUpdate?: (t: number) => void;
initialTime?: number;
forcePaused?: boolean;
}
export const NativeVideoPlayer = forwardRef<NativeVideoPlayerRef, Props>(
function NativeVideoPlayer(
{
playData,
qualities,
currentQn,
onQualityChange,
onFullscreen,
style,
bvid,
cid,
danmakus,
isFullscreen,
onTimeUpdate,
initialTime,
forcePaused,
}: Props,
ref,
) {
const { width: SCREEN_W, height: SCREEN_H } = useWindowDimensions();
const VIDEO_H = SCREEN_W * 0.5625;
const theme = useTheme();
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 currentTimeRef = useRef(0);
const [duration, setDuration] = useState(0);
const durationRef = useRef(0);
const lastProgressUpdate = useRef(0);
const [showQuality, setShowQuality] = useState(false);
const [buffered, setBuffered] = useState(0);
const [isSeeking, setIsSeeking] = useState(false);
const isSeekingRef = useRef(false);
const [touchX, setTouchX] = useState<number | null>(null);
const touchXRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const barOffsetX = useRef(0);
const barWidthRef = useRef(300);
const trackRef = useRef<View>(null);
const [shots, setShots] = useState<VideoShotData | null>(null);
const [showDanmaku, setShowDanmaku] = useState(true);
const videoRef = useRef<VideoRef>(null);
useImperativeHandle(ref, () => ({
seek: (t: number) => {
videoRef.current?.seek(t);
},
setPaused: (v: boolean) => {
setPaused(v);
},
}));
const currentDesc =
qualities.find((q) => q.qn === currentQn)?.desc ??
String(currentQn || "HD");
// 解析播放链接dash 需要构建 mpd uri普通链接直接取第一个 durl。使用 useEffect 监听 playData 和 currentQn 变化,确保每次切换视频或清晰度时都能正确更新播放链接。错误处理逻辑保证即使 dash mpd 构建失败也能回退到普通链接,提升兼容性。
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]);
// 获取视频截图数据,供进度条预览使用。依赖 bvid 和 cid确保在视频切换时重新获取截图。使用 cancelled 标志避免在组件卸载后更新状态,防止内存泄漏和潜在的错误。
useEffect(() => {
if (!bvid || !cid) return;
let cancelled = false;
getVideoShot(bvid, cid).then((shotData) => {
if (cancelled) return;
if (shotData?.image?.length) {
setShots(shotData);
}
});
return () => {
cancelled = true;
};
}, [bvid, cid]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
// 控制栏自动隐藏逻辑每次用户交互后重置计时器3秒无交互则隐藏。使用 useRef 存储计时器 ID 和拖动状态,避免闭包问题导致的计时器失效或误触发。
const resetHideTimer = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
if (!isSeekingRef.current) {
hideTimer.current = setTimeout(
() => setShowControls(false),
HIDE_DELAY,
);
}
}, []);
// 显示控制栏并重置隐藏计时器,确保用户每次交互后都有足够时间查看控制栏。依赖 resetHideTimer 保持稳定引用,避免不必要的重新渲染。
const showAndReset = useCallback(() => {
setShowControls(true);
resetHideTimer();
}, [resetHideTimer]);
// 点击视频区域切换控制栏显示状态,显示时重置隐藏计时器,隐藏时直接隐藏。使用 useCallback 优化性能,避免不必要的函数重新创建。
const handleTap = useCallback(() => {
setShowControls((prev) => {
if (!prev) {
resetHideTimer();
return true;
}
if (hideTimer.current) clearTimeout(hideTimer.current);
return false;
});
}, [resetHideTimer]);
// 组件卸载时清理隐藏计时器,避免内存泄漏和潜在的状态更新错误。依赖项为空数组确保只在挂载和卸载时执行一次。
useEffect(() => {
resetHideTimer();
return () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
};
}, []);
const measureTrack = useCallback(() => {
trackRef.current?.measureInWindow((x, _y, w) => {
if (w > 0) {
barOffsetX.current = x;
barWidthRef.current = w;
}
});
}, []);
// 使用 PanResponder 实现进度条拖动,支持在拖动过程中显示预览图。通过 touchXRef 和 rafRef 优化拖动性能,避免频繁更新状态导致的卡顿。用户松开拖动时,根据最终位置计算对应的时间点并跳转,同时清理状态和隐藏预览图。
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (_, gs) => {
isSeekingRef.current = true;
setIsSeeking(true);
setShowControls(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
const x = clamp(gs.x0 - barOffsetX.current, 0, barWidthRef.current);
touchXRef.current = x;
setTouchX(x);
},
onPanResponderMove: (_, gs) => {
touchXRef.current = clamp(
gs.moveX - barOffsetX.current,
0,
barWidthRef.current,
);
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
setTouchX(touchXRef.current);
rafRef.current = null;
});
}
},
// 用户松开拖动,或拖动被中断(如来电),都视为结束拖动,需要清理状态和隐藏预览
onPanResponderRelease: (_, gs) => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
const ratio = clamp(
(gs.moveX - barOffsetX.current) / barWidthRef.current,
0,
1,
);
const t = ratio * durationRef.current;
videoRef.current?.seek(t);
setCurrentTime(t);
touchXRef.current = null;
setTouchX(null);
isSeekingRef.current = false;
setIsSeeking(false);
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(
() => setShowControls(false),
HIDE_DELAY,
);
},
onPanResponderTerminate: () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
touchXRef.current = null;
setTouchX(null);
isSeekingRef.current = false;
setIsSeeking(false);
},
}),
).current;
// 进度条上触摸位置对应的时间点比例0-1。非拖动状态为 null
const touchRatio =
touchX !== null ? clamp(touchX / barWidthRef.current, 0, 1) : null;
const progressRatio =
duration > 0 ? clamp(currentTime / duration, 0, 1) : 0;
const bufferedRatio = duration > 0 ? clamp(buffered / duration, 0, 1) : 0;
const THUMB_DISPLAY_W = 120; // scaled display width
const renderThumbnail = () => {
if (touchRatio === null || !shots || !isSeeking) return null;
const {
img_x_size: TW,
img_y_size: TH,
img_x_len,
img_y_len,
image,
index,
} = shots;
const framesPerSheet = img_x_len * img_y_len;
const totalFrames = framesPerSheet * image.length;
const seekTime = touchRatio * duration;
// 通过时间戳索引找到最接近的帧,若无索引则均匀映射到总帧数上
const frameIdx =
index?.length && duration > 0
? clamp(findFrameByTime(index, seekTime), 0, index.length - 1)
: clamp(
Math.floor(touchRatio * (totalFrames - 1)),
0,
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 scale = THUMB_DISPLAY_W / TW;
const DW = THUMB_DISPLAY_W;
const DH = Math.round(TH * scale);
const trackLeft = barOffsetX.current;
const absLeft = clamp(
trackLeft + (touchX ?? 0) - DW / 2,
0,
SCREEN_W - DW,
);
// 兼容处理图床地址,确保以 http(s) 协议开头
const sheetUrl = image[sheetIdx].startsWith("//")
? `https:${image[sheetIdx]}`
: image[sheetIdx];
return (
<View
style={[styles.thumbPreview, { left: absLeft, width: DW }]}
pointerEvents="none"
>
<View
style={{
width: DW,
height: DH,
overflow: "hidden",
borderRadius: 4,
}}
>
<Image
source={{ uri: sheetUrl, headers: HEADERS }}
style={{
position: "absolute",
width: TW * img_x_len * scale,
height: TH * img_y_len * scale,
left: -col * DW,
top: -row * DH,
}}
/>
</View>
<Text style={styles.thumbTime}>
{formatDuration(Math.floor(seekTime))}
</Text>
</View>
);
};
return (
<View
style={[
isFullscreen
? styles.fsContainer
: [styles.container, { width: SCREEN_W, height: VIDEO_H }],
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={!!(forcePaused || paused)}
progressUpdateInterval={500}
onProgress={({
currentTime: ct,
seekableDuration: dur,
playableDuration: buf,
}) => {
currentTimeRef.current = ct;
onTimeUpdate?.(ct);
// 拖动进度条时跳过 UI 更新,避免与用户拖动冲突
if (isSeekingRef.current) return;
const now = Date.now();
if (now - lastProgressUpdate.current < 450) return;
lastProgressUpdate.current = now;
setCurrentTime(ct);
if (dur > 0 && Math.abs(dur - durationRef.current) > 1) setDuration(dur);
setBuffered(buf);
}}
onLoad={() => {
if (initialTime && initialTime > 0) {
videoRef.current?.seek(initialTime);
}
// seek 后部分播放器不恢复播放,先暂停再恢复,强制触发 prop 变化
if (!forcePaused) {
setPaused(true);
requestAnimationFrame(() => setPaused(false));
}
}}
onError={(e) => {
// 杜比视界播放失败时自动降级到 1080P
if (currentQn === 126) {
onQualityChange(80);
return;
}
console.warn("Video playback error:", e);
}}
/>
) : (
<View style={styles.placeholder} />
)}
{isFullscreen && !!danmakus?.length && (
<DanmakuOverlay
danmakus={danmakus}
currentTime={currentTime}
screenWidth={SCREEN_W}
screenHeight={SCREEN_H}
visible={showDanmaku}
/>
)}
<TouchableWithoutFeedback onPress={handleTap}>
<View style={StyleSheet.absoluteFill} />
</TouchableWithoutFeedback>
{showControls && (
<>
{/* 小窗口 */}
<LinearGradient
colors={["rgba(0,0,0,0.55)", "transparent"]}
style={styles.topBar}
pointerEvents="box-none"
></LinearGradient>
<TouchableOpacity
style={styles.centerBtn}
onPress={() => {
setPaused((p) => !p);
showAndReset();
}}
>
<View style={styles.centerBtnBg}>
<Ionicons
name={paused ? "play" : "pause"}
size={28}
color="#fff"
/>
</View>
</TouchableOpacity>
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)"]}
style={styles.bottomBar}
pointerEvents="box-none"
>
<View
ref={trackRef}
style={styles.trackWrapper}
onLayout={measureTrack}
{...panResponder.panHandlers}
>
<View style={styles.track}>
<View
style={[
styles.trackLayer,
{
width: `${bufferedRatio * 100}%` as any,
backgroundColor: "rgba(255,255,255,0.35)",
},
]}
/>
<View
style={[
styles.trackLayer,
{
width: `${progressRatio * 100}%` as any,
backgroundColor: "#00AEEC",
},
]}
/>
</View>
{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 */}
<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}>
{formatDuration(Math.floor(currentTime))}
</Text>
<View style={{ flex: 1 }} />
<Text style={styles.timeText}>{formatDuration(duration)}</Text>
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => setShowQuality(true)}
>
<Text style={styles.qualityText}>{currentDesc}</Text>
</TouchableOpacity>
{isFullscreen && (
<TouchableOpacity
style={styles.ctrlBtn}
onPress={() => setShowDanmaku((v) => !v)}
>
<Ionicons
name={showDanmaku ? "chatbubbles" : "chatbubbles-outline"}
size={16}
color="#fff"
/>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.ctrlBtn} onPress={onFullscreen}>
<Ionicons name="expand" size={18} color="#fff" />
</TouchableOpacity>
</View>
</LinearGradient>
</>
)}
{renderThumbnail()}
{/* 选清晰度 */}
<Modal visible={showQuality} transparent animationType="fade">
<TouchableOpacity
style={styles.modalOverlay}
onPress={() => setShowQuality(false)}
>
<View style={[styles.qualityList, { backgroundColor: theme.modalBg }]}>
<Text style={[styles.qualityTitle, { color: theme.modalText }]}></Text>
{qualities.map((q) => (
<TouchableOpacity
key={q.qn}
style={[styles.qualityItem, { borderTopColor: theme.modalBorder }]}
onPress={() => {
setShowQuality(false);
onQualityChange(q.qn);
showAndReset();
}}
>
<Text
style={[
styles.qualityItemText,
{ color: theme.modalTextSub },
q.qn === currentQn && styles.qualityItemActive,
]}
>
{q.desc}
{q.qn === 126 ? " DV" : ""}
</Text>
{q.qn === currentQn && (
<Ionicons name="checkmark" size={16} color="#00AEEC" />
)}
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
</Modal>
</View>
);
},
);
const styles = StyleSheet.create({
container: { backgroundColor: "#000" },
fsContainer: { flex: 1, backgroundColor: "#000" },
placeholder: { ...StyleSheet.absoluteFillObject, backgroundColor: "#000" },
topBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 56,
paddingHorizontal: 12,
paddingTop: 10,
flexDirection: "row",
justifyContent: "flex-end",
},
topBtn: { padding: 6 },
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",
},
bottomBar: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
paddingBottom: 8,
paddingTop: 32,
},
thumbPreview: { position: "absolute", bottom: 64, 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,
},
trackWrapper: {
marginHorizontal: 8,
height: BAR_H + BALL_ACTIVE,
justifyContent: "center",
position: "relative",
},
track: {
height: BAR_H,
borderRadius: 2,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.2)",
},
trackLayer: {
position: "absolute",
top: 0,
left: 0,
height: BAR_H,
},
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,
},
ctrlRow: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 8,
marginTop: 4,
},
ctrlBtn: { paddingHorizontal: 8, paddingVertical: 4 },
timeText: {
color: "#fff",
fontSize: 11,
marginHorizontal: 2,
fontWeight: "600",
},
qualityText: { color: "#fff", fontSize: 11, fontWeight: "600" },
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" },
});

111
components/VideoCard.tsx Normal file
View File

@@ -0,0 +1,111 @@
import React from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Dimensions,
} from "react-native";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
import type { VideoItem } from "../services/types";
import { formatCount, formatDuration } from "../utils/format";
import { coverImageUrl } from "../utils/imageUrl";
import { useSettingsStore } from "../store/settingsStore";
import { useTheme } from "../utils/theme";
const { width } = Dimensions.get("window");
const CARD_WIDTH = (width - 14) / 2;
interface Props {
item: VideoItem;
onPress: () => void;
}
export const VideoCard = React.memo(function VideoCard({ item, onPress }: Props) {
const trafficSaving = useSettingsStore(s => s.trafficSaving);
const theme = useTheme();
return (
<TouchableOpacity
style={[styles.card, { backgroundColor: theme.card }]}
onPress={onPress}
activeOpacity={0.85}
>
<View style={styles.thumbContainer}>
<Image
source={{ uri: coverImageUrl(item.pic, trafficSaving ? 'normal' : 'hd') }}
style={[styles.thumb, { backgroundColor: theme.card }]}
contentFit="cover"
transition={200}
recyclingKey={item.bvid}
/>
<View style={styles.meta}>
<Ionicons name="play" size={11} color="#fff" />
<Text style={styles.metaText}>
{formatCount(item.stat?.view ?? 0)}
</Text>
</View>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>
{formatDuration(item.duration)}
</Text>
</View>
</View>
<View style={styles.info}>
<Text style={[styles.title, { color: theme.text }]} numberOfLines={2}>
{item.title}
</Text>
<Text style={[styles.owner, { color: theme.textSub }]} numberOfLines={1}>
{item.owner?.name ?? ""}
</Text>
</View>
</TouchableOpacity>
);
});
const styles = StyleSheet.create({
card: {
width: CARD_WIDTH,
marginBottom: 6,
backgroundColor: "#fff",
borderRadius: 6,
overflow: "hidden",
},
thumbContainer: { position: "relative" },
thumb: {
width: CARD_WIDTH,
height: CARD_WIDTH * 0.5625,
backgroundColor: "#ddd",
},
durationBadge: {
position: "absolute",
bottom: 4,
right: 4,
borderRadius: 5,
paddingHorizontal: 4,
backgroundColor: "rgba(0,0,0,0.6)",
paddingVertical: 0,
},
durationText: { color: "#fff", fontSize: 10 },
info: { padding: 6 },
title: {
fontSize: 12,
color: "#212121",
height: 33,
marginBottom: 4,
},
owner: { fontSize: 11, color: "#999", marginTop: 2 },
meta: {
position: "absolute",
bottom: 4,
left: 4,
paddingHorizontal: 4,
borderRadius: 5,
backgroundColor: "rgba(0,0,0,0.6)",
flexDirection: "row",
alignItems: "center",
paddingVertical: 0,
gap: 2,
},
metaText: { fontSize: 10, color: "#fff" },
});

121
components/VideoPlayer.tsx Normal file
View File

@@ -0,0 +1,121 @@
import React, { useState, useRef, useEffect } from 'react';
import { View, StyleSheet, Text, Platform, Modal, StatusBar, useWindowDimensions } from 'react-native';
// expo-screen-orientation requires a dev build; gracefully degrade in Expo Go
let ScreenOrientation: typeof import('expo-screen-orientation') | null = null;
try { ScreenOrientation = require('expo-screen-orientation'); } catch {}
import { NativeVideoPlayer, type NativeVideoPlayerRef } from './NativeVideoPlayer';
import type { PlayUrlResponse, DanmakuItem } from '../services/types';
interface Props {
playData: PlayUrlResponse | null;
qualities: { qn: number; desc: string }[];
currentQn: number;
onQualityChange: (qn: number) => void;
bvid?: string;
cid?: number;
danmakus?: DanmakuItem[];
onTimeUpdate?: (t: number) => void;
}
export function VideoPlayer({ playData, qualities, currentQn, onQualityChange, bvid, cid, danmakus, onTimeUpdate }: Props) {
const [fullscreen, setFullscreen] = useState(false);
const { width, height } = useWindowDimensions();
const VIDEO_HEIGHT = width * 0.5625;
const needsRotation = !ScreenOrientation && fullscreen;
const lastTimeRef = useRef(0);
const portraitRef = useRef<NativeVideoPlayerRef>(null);
const handleEnterFullscreen = async () => {
if (Platform.OS !== 'web')
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT);
setFullscreen(true);
};
const handleExitFullscreen = async () => {
setFullscreen(false);
if (Platform.OS !== 'web')
await ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
};
useEffect(() => {
return () => {
if (Platform.OS !== 'web')
ScreenOrientation?.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
};
}, []);
if (!playData) {
return (
<View style={[{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }, styles.placeholder]}>
<Text style={styles.placeholderText}>...</Text>
</View>
);
}
if (Platform.OS === 'web') {
const url = playData.durl?.[0]?.url ?? '';
return (
<View style={{ width, height: VIDEO_HEIGHT, backgroundColor: '#000' }}>
<video
src={url}
style={{ width: '100%', height: '100%', backgroundColor: '#000' } as any}
controls
playsInline
/>
</View>
);
}
return (
<>
{/* 竖屏和全屏互斥渲染,避免同时挂载两个视频解码器 */}
{!fullscreen && (
<NativeVideoPlayer
ref={portraitRef}
playData={playData}
qualities={qualities}
currentQn={currentQn}
onQualityChange={onQualityChange}
onFullscreen={handleEnterFullscreen}
bvid={bvid}
cid={cid}
isFullscreen={false}
initialTime={lastTimeRef.current}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
/>
)}
{fullscreen && (
<Modal visible animationType="none" statusBarTranslucent>
<StatusBar hidden />
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
<View style={needsRotation
? { width: height, height: width, transform: [{ rotate: '90deg' }] }
: { flex: 1, width: '100%' }
}>
<NativeVideoPlayer
playData={playData}
qualities={qualities}
currentQn={currentQn}
onQualityChange={onQualityChange}
onFullscreen={handleExitFullscreen}
bvid={bvid}
cid={cid}
danmakus={danmakus}
isFullscreen={true}
initialTime={lastTimeRef.current}
onTimeUpdate={(t) => { lastTimeRef.current = t; onTimeUpdate?.(t); }}
style={needsRotation ? { width: height, height: width } : { flex: 1 }}
/>
</View>
</View>
</Modal>
)}
</>
);
}
const styles = StyleSheet.create({
placeholder: { justifyContent: 'center', alignItems: 'center' },
placeholderText: { color: '#fff', fontSize: 14 },
});

134
dev-proxy.js Normal file
View File

@@ -0,0 +1,134 @@
const http = require('http');
const https = require('https');
const zlib = require('zlib');
const express = require('express');
const WsLib = require('ws');
const app = express();
// CORS: allow any local origin (Expo web dev server)
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Buvid3, X-Sessdata');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
function makeProxy(targetHost) {
return (req, res) => {
const buvid3 = req.headers['x-buvid3'] || '';
const sessdata = req.headers['x-sessdata'] || '';
const cookies = [
buvid3 && `buvid3=${buvid3}`,
sessdata && `SESSDATA=${sessdata}`,
].filter(Boolean).join('; ');
const options = {
hostname: targetHost,
path: req.url,
method: req.method,
headers: {
'Cookie': cookies,
'Referer': 'https://www.JKVideo.com',
'Origin': 'https://www.JKVideo.com',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Accept-Encoding': 'identity',
},
};
const proxy = https.request(options, (proxyRes) => {
// On successful QR login, extract SESSDATA from set-cookie and relay via custom header
const setCookies = proxyRes.headers['set-cookie'] || [];
const match = setCookies.find(c => c.includes('SESSDATA='));
if (match) {
const val = match.split(';')[0].replace('SESSDATA=', '');
res.setHeader('X-Sessdata', val);
}
res.writeHead(proxyRes.statusCode, {
'Content-Type': proxyRes.headers['content-type'] || 'application/json',
});
proxyRes.pipe(res);
});
proxy.on('error', (err) => res.status(502).json({ error: err.message }));
req.pipe(proxy);
};
}
app.use('/video-api', makeProxy('api.JKVideo.com'));
app.use('/passport-api', makeProxy('passport.JKVideo.com'));
app.use('/live-api', makeProxy('api.live.JKVideo.com'));
// Dedicated comment proxy: buffer response and decompress by magic bytes (not Content-Encoding header)
app.use('/comment-api', (req, res) => {
const options = {
hostname: 'comment.JKVideo.com',
path: req.url,
method: req.method,
headers: {
'Referer': 'https://www.JKVideo.com',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
};
const proxy = https.request(options, (proxyRes) => {
res.setHeader('Content-Type', proxyRes.headers['content-type'] || 'text/xml; charset=utf-8');
const chunks = [];
proxyRes.on('data', chunk => chunks.push(chunk));
proxyRes.on('end', () => {
const buf = Buffer.concat(chunks);
if (buf[0] === 0x1f && buf[1] === 0x8b) {
// actual gzip data — decompress regardless of Content-Encoding header
zlib.gunzip(buf, (err, result) => {
if (err) res.status(502).end('gunzip error: ' + err.message);
else res.end(result);
});
} else {
res.end(buf);
}
});
proxyRes.on('error', (err) => res.status(502).json({ error: err.message }));
});
proxy.on('error', (err) => res.status(502).json({ error: err.message }));
req.pipe(proxy);
});
// Image CDN proxy — strips the host segment and forwards to the real CDN with Referer
app.use('/img-proxy', (req, res) => {
const parts = req.url.split('/').filter(Boolean);
const host = parts[0];
if (!host || !host.endsWith('.hdslb.com')) return res.status(403).end();
req.url = '/' + parts.slice(1).join('/');
makeProxy(host)(req, res);
});
const PORT = process.env.PROXY_PORT || 3001;
const server = http.createServer(app);
// WebSocket relay — Android Expo Go often can't reach live chat servers directly.
// Device connects here; proxy opens the upstream WSS connection and relays all frames.
const wss = new WsLib.Server({ server, path: '/danmaku-ws' });
wss.on('connection', (clientWs, req) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
const target = url.searchParams.get('host');
if (!target || !target.includes('JKVideo.com')) {
clientWs.close(4001, 'invalid target');
return;
}
console.log('[ws-relay] →', target);
const upstream = new WsLib(target, { headers: { Origin: 'https://live.JKVideo.com' }, perMessageDeflate: false });
upstream.on('open', () => console.log('[ws-relay] upstream open'));
upstream.on('message', data => { if (clientWs.readyState === 1) clientWs.send(data, { binary: true }); });
upstream.on('error', err => { console.error('[ws-relay] upstream error:', err.message); clientWs.close(); });
upstream.on('close', () => clientWs.close());
clientWs.on('message', data => { if (upstream.readyState === 1) upstream.send(data); });
clientWs.on('close', () => upstream.close());
clientWs.on('error', () => upstream.close());
});
server.listen(PORT, '0.0.0.0', () =>
console.log(`[Proxy] http://localhost:${PORT} ws://<LAN-IP>:${PORT}/danmaku-ws`)
);

28
eas.json Normal file
View File

@@ -0,0 +1,28 @@
{
"cli": {
"version": ">= 16.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"channel": "production",
"env": {
"EXPO_PUBLIC_APP_ENV": "production",
"SENTRY_AUTH_TOKEN": "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",
"SENTRY_ORG": "your-org-slug",
"SENTRY_PROJECT": "your-project-slug"
}
}
},
"submit": {
"production": {}
}
}

133
hooks/useCheckUpdate.ts Normal file
View File

@@ -0,0 +1,133 @@
import { useState } from 'react';
import { Alert, Linking, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import Constants from 'expo-constants';
const GITHUB_API = 'https://api.github.com/repos/tiajinsha/JKVideo/releases/latest';
function compareVersions(a: string, b: string): number {
const pa = a.replace(/^v/, '').split('.').map(Number);
const pb = b.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return 1;
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return -1;
}
return 0;
}
export function useCheckUpdate() {
const currentVersion = Constants.expoConfig?.version ?? '0.0.0';
const [isChecking, setIsChecking] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const checkUpdate = async () => {
setIsChecking(true);
try {
const res = await fetch(GITHUB_API, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
const data = await res.json();
const latestVersion: string = data.tag_name ?? '';
const apkAsset = (data.assets as any[]).find((a) =>
(a.name as string).endsWith('.apk')
);
const downloadUrl: string = apkAsset?.browser_download_url ?? '';
const releaseNotes: string = data.body ?? '';
if (compareVersions(latestVersion, currentVersion) <= 0) {
Alert.alert('已是最新版本', `当前版本 v${currentVersion} 已是最新`);
return;
}
Alert.alert(
`发现新版本 ${latestVersion}`,
releaseNotes || '有新版本可用,是否立即下载?',
[
{ text: '取消', style: 'cancel' },
{
text: '浏览器下载',
onPress: () => Linking.openURL(downloadUrl),
},
{
text: '应用内下载',
onPress: () => downloadAndInstall(downloadUrl, latestVersion),
},
]
);
} catch (e: any) {
Alert.alert('检查失败', e?.message ?? '网络错误,请稍后重试');
} finally {
setIsChecking(false);
}
};
const openInstallSettings = () => {
IntentLauncher.startActivityAsync(
'android.settings.MANAGE_UNKNOWN_APP_SOURCES',
{ data: 'package:com.anonymous.jkvideo' }
).catch(() => {
// 部分旧版 Android 不支持精确跳转,回退到通用安全设置
IntentLauncher.startActivityAsync('android.settings.SECURITY_SETTINGS');
});
};
const triggerInstall = async (localUri: string) => {
const contentUri = await FileSystem.getContentUriAsync(localUri);
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: contentUri,
flags: 1,
type: 'application/vnd.android.package-archive',
});
};
const downloadAndInstall = async (url: string, version: string) => {
if (Platform.OS !== 'android') {
Alert.alert('提示', '自动安装仅支持 Android 设备');
return;
}
const localUri = FileSystem.cacheDirectory + `JKVideo-${version}.apk`;
try {
setDownloadProgress(0);
const downloadResumable = FileSystem.createDownloadResumable(
url,
localUri,
{},
({ totalBytesWritten, totalBytesExpectedToWrite }) => {
if (totalBytesExpectedToWrite > 0) {
setDownloadProgress(
Math.round((totalBytesWritten / totalBytesExpectedToWrite) * 100)
);
}
}
);
await downloadResumable.downloadAsync();
setDownloadProgress(null);
// Android 8.0+ 需要用户在系统设置中为本应用开启「安装未知应用」权限。
// 系统拒绝时不会抛出 JS 异常,因此下载完成后主动引导。
Alert.alert(
'下载完成,准备安装',
'如果点击「安装」后提示无权限,请先点击「去设置」,为 JKVideo 开启「允许安装未知应用」,然后返回重试。',
[
{ text: '去设置', onPress: openInstallSettings },
{
text: '安装',
onPress: () => {
triggerInstall(localUri).catch((e: any) => {
Alert.alert('安装失败', e?.message ?? '请在设置中开启「安装未知应用」权限后重试');
});
},
},
]
);
} catch (e: any) {
setDownloadProgress(null);
Alert.alert('下载失败', e?.message ?? '请稍后重试');
}
};
return { currentVersion, isChecking, downloadProgress, checkUpdate };
}

49
hooks/useComments.ts Normal file
View File

@@ -0,0 +1,49 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { getComments } from '../services/api';
import type { Comment } from '../services/types';
export function useComments(aid: number, sort: number) {
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadingRef = useRef(false);
const hasMoreRef = useRef(true);
const sortRef = useRef(sort);
const aidRef = useRef(aid);
const cursorRef = useRef(''); // empty = first page
aidRef.current = aid;
useEffect(() => {
if (sortRef.current === sort) return;
sortRef.current = sort;
cursorRef.current = '';
hasMoreRef.current = true;
setComments([]);
setHasMore(true);
}, [sort]);
const load = useCallback(async () => {
if (loadingRef.current || !hasMoreRef.current || !aidRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const isFirstPage = cursorRef.current === '';
const { replies, nextCursor, isEnd } = await getComments(aidRef.current, cursorRef.current, sortRef.current);
cursorRef.current = nextCursor;
setComments(prev => isFirstPage ? replies : [...prev, ...replies]);
if (isEnd || replies.length === 0) {
hasMoreRef.current = false;
setHasMore(false);
}
} catch (e) {
console.error('Failed to load comments', e);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, []);
return { comments, loading, hasMore, load };
}

167
hooks/useDownload.ts Normal file
View File

@@ -0,0 +1,167 @@
import * as FileSystem from 'expo-file-system/legacy';
import { AppState } from 'react-native';
import { useDownloadStore } from '../store/downloadStore';
import { getPlayUrlForDownload } from '../services/api';
const lastReportedProgress: Record<string, number> = {};
const QUALITY_LABELS: Record<number, string> = {
16: '360P', 32: '480P', 64: '720P',
80: '1080P', 112: '1080P+', 116: '1080P60',
};
/** 等待 App 回到前台 */
function waitForActive(): Promise<void> {
return new Promise((resolve) => {
if (AppState.currentState === 'active') { resolve(); return; }
const sub = AppState.addEventListener('change', (s) => {
if (s === 'active') { sub.remove(); resolve(); }
});
});
}
/** 当前是否在后台 */
function isBackground() {
return AppState.currentState !== 'active';
}
/** 读取本地文件实际大小 */
async function readFileSize(uri: string): Promise<number | undefined> {
try {
const info = await FileSystem.getInfoAsync(uri, { size: true });
if (info.exists) return (info as any).size as number;
} catch {}
return undefined;
}
export function useDownload() {
const { tasks, addTask, updateTask, removeTask } = useDownloadStore();
function taskKey(bvid: string, qn: number) { return `${bvid}_${qn}`; }
function localPath(bvid: string, qn: number) {
return `${FileSystem.documentDirectory}${bvid}_${qn}.mp4`;
}
async function startDownload(
bvid: string, cid: number, qn: number,
qdesc: string, title: string, cover: string,
) {
const key = taskKey(bvid, qn);
if (tasks[key]?.status === 'downloading') return;
addTask(key, {
bvid, title, cover, qn,
qdesc: qdesc || QUALITY_LABELS[qn] || String(qn),
status: 'downloading', progress: 0, createdAt: Date.now(),
});
// 最多重新拉取 URL 并重试一次(应对后台时 URL 过期的情况)
for (let attempt = 0; attempt < 2; attempt++) {
const success = await attemptDownload(key, bvid, cid, qn);
if (success !== 'retry') return;
// 需要重试:重新拉取 URL
updateTask(key, { status: 'downloading', progress: 0 });
lastReportedProgress[key] = -1;
}
updateTask(key, { status: 'error', error: '下载失败,请重试' });
}
/** 执行一次下载尝试。返回 'done' | 'error' | 'retry' */
async function attemptDownload(
key: string, bvid: string, cid: number, qn: number,
): Promise<'done' | 'error' | 'retry'> {
try {
const url = await getPlayUrlForDownload(bvid, cid, qn);
const dest = localPath(bvid, qn);
const headers = {};
const progressCallback = (p: FileSystem.DownloadProgressData) => {
const { totalBytesWritten, totalBytesExpectedToWrite } = p;
const progress = totalBytesExpectedToWrite > 0
? totalBytesWritten / totalBytesExpectedToWrite : 0;
const last = lastReportedProgress[key] ?? -1;
if (progress - last >= 0.01) {
lastReportedProgress[key] = progress;
updateTask(key, { progress });
}
};
const resumable = FileSystem.createDownloadResumable(url, dest, { headers }, progressCallback);
// 进入后台时主动暂停,抢在 OS 断连之前
let bgPaused = false;
const appStateSub = AppState.addEventListener('change', async (state) => {
if ((state === 'background' || state === 'inactive') && !bgPaused) {
bgPaused = true;
try { await resumable.pauseAsync(); } catch {}
}
});
let result: FileSystem.DownloadResult | null = null;
try {
result = await resumable.downloadAsync();
} catch (e: any) {
// downloadAsync 抛出多为后台断连connection abort或被 pauseAsync 中断
if (!isBackground() && !bgPaused) {
// 真实网络错误,非后台原因
appStateSub.remove();
delete lastReportedProgress[key];
const msg = e?.message ?? '下载失败';
updateTask(key, { status: 'error', error: msg.length > 40 ? msg.slice(0, 40) + '...' : msg });
return 'error';
}
// 后台引发的中断,走下面的续传逻辑
result = null;
} finally {
appStateSub.remove();
}
// ── 续传逻辑result 为 null 说明被暂停或中断 ──
if (!result?.uri) {
// 等 App 回到前台
if (isBackground()) await waitForActive();
// 尝试从断点续传
try {
result = await resumable.resumeAsync();
} catch {
result = null;
}
// 续传仍失败URL 可能过期),通知上层重试
if (!result?.uri) {
delete lastReportedProgress[key];
return 'retry';
}
}
// ── 下载完成 ──
delete lastReportedProgress[key];
const fileSize = await readFileSize(result.uri);
updateTask(key, {
status: 'done', progress: 1, localUri: result.uri,
...(fileSize ? { fileSize } : {}),
});
return 'done';
} catch (e: any) {
delete lastReportedProgress[key];
console.error('[Download] failed:', e);
const msg = e?.message ?? '下载失败';
updateTask(key, { status: 'error', error: msg.length > 40 ? msg.slice(0, 40) + '...' : msg });
return 'error';
}
}
function getLocalUri(bvid: string, qn: number): string | undefined {
return tasks[taskKey(bvid, qn)]?.localUri;
}
function cancelDownload(bvid: string, qn: number) {
removeTask(taskKey(bvid, qn));
}
return { tasks, startDownload, getLocalUri, cancelDownload, taskKey };
}

103
hooks/useLiveDanmaku.ts Normal file
View File

@@ -0,0 +1,103 @@
import { useState, useEffect, useRef } from 'react';
import { getLiveDanmakuHistory } from '../services/api';
import type { DanmakuItem } from '../services/types';
const POLL_INTERVAL = 3000;
// 匹配 admin 消息中的礼物信息,如 "xxx 赠送了 辣条 x5" 或 "xxx 投喂 小心心 ×1"
const GIFT_PATTERN = /(?:赠送|投喂)\s*(?:了\s*)?(.+?)\s*[xX×]\s*(\d+)/;
// 常见礼物名列表,用于匹配
const KNOWN_GIFTS = new Set([
'辣条', '小心心', '打call', '干杯', '比心',
'吃瓜', '花式夸夸', '告白气球', '小电视飞船',
]);
export function useLiveDanmaku(roomId: number): {
danmakus: DanmakuItem[];
giftCounts: Record<string, number>;
} {
const [danmakus, setDanmakus] = useState<DanmakuItem[]>([]);
const [giftCounts, setGiftCounts] = useState<Record<string, number>>({});
const seenTextsRef = useRef<Set<string>>(new Set());
const seenAdminRef = useRef<Set<string>>(new Set());
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!roomId) return;
setDanmakus([]);
setGiftCounts({});
seenTextsRef.current.clear();
seenAdminRef.current.clear();
let cancelled = false;
async function poll() {
try {
const { danmakus: items, adminMsgs } = await getLiveDanmakuHistory(roomId);
if (cancelled) return;
// 去重弹幕
const newItems = items.filter(item => {
const key = `${item.uname ?? ''}:${item.text}`;
if (seenTextsRef.current.has(key)) return false;
seenTextsRef.current.add(key);
return true;
});
if (newItems.length > 0) {
setDanmakus(prev => [...prev, ...newItems]);
}
// 解析 admin 消息中的礼物
const newGifts: Record<string, number> = {};
for (const msg of adminMsgs) {
console.log(msg,123)
// 去重 admin 消息
if (seenAdminRef.current.has(msg)) continue;
seenAdminRef.current.add(msg);
const match = msg.match(GIFT_PATTERN);
if (match) {
const giftName = match[1].trim();
const count = parseInt(match[2], 10);
if (KNOWN_GIFTS.has(giftName) && count > 0) {
newGifts[giftName] = (newGifts[giftName] ?? 0) + count;
}
}
}
if (Object.keys(newGifts).length > 0) {
setGiftCounts(prev => {
const next = { ...prev };
for (const [name, count] of Object.entries(newGifts)) {
next[name] = (next[name] ?? 0) + count;
}
return next;
});
}
// 防止 seen set 无限增长
if (seenTextsRef.current.size > 2000) {
const arr = Array.from(seenTextsRef.current);
seenTextsRef.current = new Set(arr.slice(-1000));
}
if (seenAdminRef.current.size > 500) {
const arr = Array.from(seenAdminRef.current);
seenAdminRef.current = new Set(arr.slice(-250));
}
} catch (e) {
console.warn('[danmaku] poll failed:', e);
}
if (!cancelled) {
timerRef.current = setTimeout(poll, POLL_INTERVAL);
}
}
poll();
return () => {
cancelled = true;
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [roomId]);
return { danmakus, giftCounts };
}

68
hooks/useLiveDetail.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getLiveRoomDetail, getLiveAnchorInfo, getLiveStreamUrl } from '../services/api';
import type { LiveRoomDetail, LiveAnchorInfo, LiveStreamInfo } from '../services/types';
interface LiveDetailState {
room: LiveRoomDetail | null;
anchor: LiveAnchorInfo | null;
stream: LiveStreamInfo | null;
loading: boolean;
error: string | null;
}
export function useLiveDetail(roomId: number) {
const [state, setState] = useState<LiveDetailState>({
room: null,
anchor: null,
stream: null,
loading: true,
error: null,
});
// 用 ref 追踪最新的 roomId避免 cancelled 闭包问题
const latestRoomId = useRef(roomId);
latestRoomId.current = roomId;
useEffect(() => {
if (!roomId) return;
setState({ room: null, anchor: null, stream: null, loading: true, error: null });
const fetchId = roomId; // 捕获当前 roomId
async function doFetch() {
try {
const [room, anchor] = await Promise.all([
getLiveRoomDetail(fetchId),
getLiveAnchorInfo(fetchId),
]);
// 仅在 roomId 未变化时更新状态(替代 cancelled 模式)
if (latestRoomId.current !== fetchId) return;
let stream: LiveStreamInfo = { hlsUrl: '', flvUrl: '', qn: 0, qualities: [] };
if (room?.live_status === 1) {
stream = await getLiveStreamUrl(fetchId);
}
if (latestRoomId.current !== fetchId) return;
setState({ room, anchor, stream, loading: false, error: null });
} catch (e: any) {
if (latestRoomId.current !== fetchId) return;
setState(prev => ({ ...prev, loading: false, error: e?.message ?? '加载失败' }));
}
}
doFetch();
}, [roomId]);
const changeQuality = useCallback(async (qn: number) => {
try {
const stream = await getLiveStreamUrl(roomId, qn);
setState(prev => ({ ...prev, stream: { ...stream, qn } }));
} catch { /* ignore */ }
}, [roomId]);
return { ...state, changeQuality };
}

59
hooks/useLiveList.ts Normal file
View File

@@ -0,0 +1,59 @@
import { useState, useCallback, useRef } from 'react';
import { getLiveList } from '../services/api';
import type { LiveRoom } from '../services/types';
export function useLiveList() {
const [rooms, setRooms] = useState<LiveRoom[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const loadingRef = useRef(false);
const pendingRef = useRef(false);
const pageRef = useRef(1);
const areaIdRef = useRef(0);
const load = useCallback(async (reset = false, parentAreaId?: number) => {
if (loadingRef.current) {
if (!reset) pendingRef.current = true;
return;
}
loadingRef.current = true;
pendingRef.current = false;
if (parentAreaId !== undefined) {
areaIdRef.current = parentAreaId;
}
if (reset) {
pageRef.current = 1;
setRooms([]);
}
const page = pageRef.current;
setLoading(true);
try {
const data = await getLiveList(page, areaIdRef.current);
setRooms(prev => reset ? data : [...prev, ...data]);
pageRef.current = page + 1;
} catch (e) {
console.error('Failed to load live rooms', e);
} finally {
loadingRef.current = false;
setRefreshing(false);
if (pendingRef.current) {
pendingRef.current = false;
load();
} else {
setLoading(false);
}
}
}, []);
const refresh = useCallback((parentAreaId?: number) => {
setRefreshing(true);
load(true, parentAreaId);
}, [load]);
return { rooms, loading, refreshing, load, refresh };
}

26
hooks/useRelatedVideos.ts Normal file
View File

@@ -0,0 +1,26 @@
import { useState, useCallback, useRef } from 'react';
import { getVideoRelated } from '../services/api';
import type { VideoItem } from '../services/types';
export function useRelatedVideos(bvid: string) {
const [videos, setVideos] = useState<VideoItem[]>([]);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const load = useCallback(async () => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const data = await getVideoRelated(bvid);
setVideos(data);
} catch (e) {
console.warn('useRelatedVideos: failed', e);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [bvid]);
return { videos, loading, load, hasMore: false };
}

133
hooks/useSearch.ts Normal file
View File

@@ -0,0 +1,133 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { searchVideos, getSearchSuggest, getHotSearch } from '../services/api';
import type { VideoItem, SearchSuggestItem, HotSearchItem } from '../services/types';
const HISTORY_KEY = 'search_history';
const MAX_HISTORY = 20;
export type SearchSort = 'default' | 'pubdate' | 'view';
async function loadHistory(): Promise<string[]> {
try {
const raw = await AsyncStorage.getItem(HISTORY_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
async function saveHistory(history: string[]) {
await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
export function useSearch() {
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState<VideoItem[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [sort, setSort] = useState<SearchSort>('default');
const [history, setHistory] = useState<string[]>([]);
const [suggestions, setSuggestions] = useState<SearchSuggestItem[]>([]);
const [hotSearches, setHotSearches] = useState<HotSearchItem[]>([]);
const loadingRef = useRef(false);
const suggestTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentSort = useRef<SearchSort>('default');
// Load history & hot searches on mount
useEffect(() => {
loadHistory().then(setHistory);
getHotSearch().then(setHotSearches);
}, []);
// Debounced suggestions
useEffect(() => {
if (suggestTimer.current) clearTimeout(suggestTimer.current);
if (!keyword.trim() || keyword.trim().length < 1) {
setSuggestions([]);
return;
}
suggestTimer.current = setTimeout(async () => {
const items = await getSearchSuggest(keyword.trim());
setSuggestions(items);
}, 300);
return () => {
if (suggestTimer.current) clearTimeout(suggestTimer.current);
};
}, [keyword]);
const addToHistory = useCallback(async (kw: string) => {
const trimmed = kw.trim();
if (!trimmed) return;
setHistory(prev => {
const filtered = prev.filter(h => h !== trimmed);
const next = [trimmed, ...filtered].slice(0, MAX_HISTORY);
saveHistory(next);
return next;
});
}, []);
const removeFromHistory = useCallback(async (kw: string) => {
setHistory(prev => {
const next = prev.filter(h => h !== kw);
saveHistory(next);
return next;
});
}, []);
const clearHistory = useCallback(async () => {
setHistory([]);
await AsyncStorage.removeItem(HISTORY_KEY);
}, []);
const search = useCallback(async (kw: string, reset = false, sortOverride?: SearchSort) => {
if (!kw.trim() || loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
setSuggestions([]);
const activeSort = sortOverride ?? currentSort.current;
const currentPage = reset ? 1 : page;
const orderParam = activeSort === 'pubdate' ? 'pubdate' : activeSort === 'view' ? 'click' : '';
try {
const items = await searchVideos(kw, currentPage, orderParam);
if (reset) {
setResults(items);
setPage(2);
addToHistory(kw);
} else {
setResults(prev => [...prev, ...items]);
setPage(p => p + 1);
}
setHasMore(items.length >= 20);
} catch {
setHasMore(false);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [page, addToHistory]);
const changeSort = useCallback((newSort: SearchSort) => {
setSort(newSort);
currentSort.current = newSort;
if (keyword.trim()) {
search(keyword, true, newSort);
}
}, [keyword, search]);
const loadMore = useCallback(() => {
if (!keyword.trim() || loadingRef.current || !hasMore) return;
search(keyword, false);
}, [keyword, hasMore, search]);
return {
keyword, setKeyword,
results, loading, hasMore,
search, loadMore,
sort, changeSort,
history, removeFromHistory, clearHistory,
suggestions,
hotSearches,
};
}

65
hooks/useVideoDetail.ts Normal file
View File

@@ -0,0 +1,65 @@
import { useState, useEffect, useRef } from 'react';
import { getVideoDetail, getPlayUrl } from '../services/api';
import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import type { VideoItem, PlayUrlResponse } from '../services/types';
export function useVideoDetail(bvid: string) {
const [video, setVideo] = useState<VideoItem | null>(null);
const [playData, setPlayData] = useState<PlayUrlResponse | null>(null);
const [qualities, setQualities] = useState<{ qn: number; desc: string }[]>([]);
const [currentQn, setCurrentQn] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const cidRef = useRef<number>(0);
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const trafficSaving = useSettingsStore(s => s.trafficSaving);
const defaultQn = trafficSaving ? 16 : 126;
async function fetchPlayData(cid: number, qn: number, updateList = false) {
const data = await getPlayUrl(bvid, cid, qn);
setPlayData(data);
setCurrentQn(data.quality);
if (updateList && data.accept_quality?.length) {
setQualities(
data.accept_quality.map((q, i) => ({
qn: q,
desc: data.accept_description?.[i] ?? String(q),
}))
);
}
}
async function changeQuality(qn: number) {
await fetchPlayData(cidRef.current, qn);
}
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
const detail = await getVideoDetail(bvid);
setVideo(detail);
const cid = detail.pages?.[0]?.cid ?? detail.cid as number;
cidRef.current = cid;
await fetchPlayData(cid, defaultQn, true);
} catch (e: any) {
setError(e.message ?? 'Load failed');
} finally {
setLoading(false);
}
}
if (bvid) fetchData();
}, [bvid]);
// 登录状态变化时重新拉取清晰度列表(登录后可能获得更高画质)
useEffect(() => {
if (cidRef.current) {
fetchPlayData(cidRef.current, defaultQn, true).catch((e) => {
console.warn('Failed to refresh quality list after login change:', e);
});
}
}, [isLoggedIn]);
return { video, playData, loading, error, qualities, currentQn, changeQuality };
}

55
hooks/useVideoList.ts Normal file
View File

@@ -0,0 +1,55 @@
import { useState, useCallback, useRef, useMemo } from 'react';
import { getRecommendFeed, getLiveList } from '../services/api';
import type { VideoItem, LiveRoom } from '../services/types';
export function useVideoList() {
const [pages, setPages] = useState<VideoItem[][]>([]);
const [liveRooms, setLiveRooms] = useState<LiveRoom[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// Use refs to avoid stale closures — load() has stable identity
const loadingRef = useRef(false);
const freshIdxRef = useRef(0);
const load = useCallback(async (reset = false) => {
if (loadingRef.current) {
if (reset) setRefreshing(false);
return;
}
loadingRef.current = true;
const idx = freshIdxRef.current;
setLoading(true);
try {
const promises: [Promise<VideoItem[]>, Promise<LiveRoom[]>] = [
getRecommendFeed(idx),
(reset || idx === 0)
? getLiveList(1, 0).catch(() => [] as LiveRoom[])
: Promise.resolve([] as LiveRoom[]),
];
const [data, live] = await Promise.all(promises);
setPages(prev => reset ? [data] : [...prev, data]);
if (reset || idx === 0) {
// Take top 2 by online count
const sorted = [...live].sort((a, b) => b.online - a.online).slice(0, 10);
setLiveRooms(sorted);
}
freshIdxRef.current = idx + 1;
} catch (e) {
console.error('Failed to load videos', e);
} finally {
loadingRef.current = false;
setLoading(false);
setRefreshing(false);
}
}, []); // stable — no stale closure risk
const refresh = useCallback(() => {
console.log('Refreshing video list');
setRefreshing(true);
load(true);
}, [load]);
const videos = useMemo(() => pages.flat(), [pages]);
return { videos, pages, liveRooms, loading, refreshing, load, refresh };
}

8
index.ts Normal file
View File

@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

36
metro.config.js Normal file
View File

@@ -0,0 +1,36 @@
const path = require('path');
const { getSentryExpoConfig } = require("@sentry/react-native/metro");
const config = getSentryExpoConfig(__dirname);
// Ensure shims directory is watched by Metro
config.watchFolders = [...(config.watchFolders ?? []), path.resolve(__dirname, 'shims')];
const originalResolveRequest = config.resolver.resolveRequest;
const WEB_SHIMS = {
'react-native-pager-view': 'shims/react-native-pager-view.web.tsx',
'@sentry/react-native': 'shims/sentry-react-native.web.tsx',
'@dr.pogodin/react-native-static-server': 'shims/react-native-static-server.web.ts',
'expo-network': 'shims/expo-network.web.ts',
'expo-intent-launcher': 'shims/expo-intent-launcher.web.ts',
'react-native-video': 'shims/react-native-video.web.tsx',
'expo-file-system': 'shims/expo-file-system.web.ts',
'expo-file-system/legacy': 'shims/expo-file-system.web.ts',
'expo-clipboard': 'shims/expo-clipboard.web.ts',
};
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (platform === 'web' && WEB_SHIMS[moduleName]) {
return {
filePath: path.resolve(__dirname, WEB_SHIMS[moduleName]),
type: 'sourceFile',
};
}
if (originalResolveRequest) {
return originalResolveRequest(context, moduleName, platform);
}
return context.resolveRequest(context, moduleName, platform);
};
module.exports = config;

10257
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "jkvideo",
"version": "1.0.17",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"proxy": "node dev-proxy.js"
},
"dependencies": {
"@dr.pogodin/react-native-static-server": "^0.26.0",
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@sentry/react-native": "~7.11.0",
"axios": "^1.13.6",
"expo": "~55.0.5",
"expo-av": "^16.0.8",
"expo-clipboard": "~55.0.9",
"expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.11",
"expo-file-system": "~55.0.10",
"expo-image": "~55.0.6",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "~55.0.8",
"expo-media-library": "~55.0.10",
"expo-network": "~55.0.9",
"expo-router": "~55.0.4",
"expo-screen-orientation": "~55.0.8",
"expo-secure-store": "~55.0.9",
"expo-status-bar": "~55.0.4",
"expo-system-ui": "~55.0.9",
"pako": "^2.1.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native-pager-view": "8.0.0",
"react-native-qrcode-svg": "^6.3.21",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0",
"react-native-svg": "15.15.3",
"react-native-video": "^6.19.0",
"react-native-web": "^0.21.0",
"react-native-webview": "13.16.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/pako": "^2.0.4",
"@types/react": "~19.2.2",
"express": "^4.22.1",
"typescript": "~5.9.2"
},
"private": true
}

BIN
public/alipay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

BIN
public/p1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/p2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
public/p3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

BIN
public/p4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

BIN
public/p5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/p6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

BIN
public/wxpay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

37
scripts/bump-version.js Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
// Read app.json
const appJsonPath = path.join(root, 'app.json');
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
const currentVersion = appJson.expo.version;
// Parse semver
const parts = currentVersion.split('.').map(Number);
parts[2] += 1;
const newVersion = parts.join('.');
const newVersionCode = parts[0] * 10000 + parts[1] * 100 + parts[2];
// Update app.json
appJson.expo.version = newVersion;
fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
// Update package.json
const pkgJsonPath = path.join(root, 'package.json');
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
pkgJson.version = newVersion;
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + '\n');
// Update android/app/build.gradle if it exists (skipped in CI before prebuild)
const gradlePath = path.join(root, 'android', 'app', 'build.gradle');
if (fs.existsSync(gradlePath)) {
let gradle = fs.readFileSync(gradlePath, 'utf8');
gradle = gradle.replace(/versionCode\s+\d+/, `versionCode ${newVersionCode}`);
gradle = gradle.replace(/versionName\s+"[^"]+"/, `versionName "${newVersion}"`);
fs.writeFileSync(gradlePath, gradle);
}
console.log(newVersion);

153
services/api.ts Normal file
View File

@@ -0,0 +1,153 @@
import type {
VideoItem,
Comment,
PlayUrlResponse,
QRCodeInfo,
VideoShotData,
DanmakuItem,
LiveRoom,
LiveRoomDetail,
LiveAnchorInfo,
LiveStreamInfo,
SearchSuggestItem,
HotSearchItem,
} from './types';
import {
MOCK_VIDEOS,
MOCK_VIDEO_DETAIL,
MOCK_PLAY_URL,
MOCK_LIVE_ROOMS,
MOCK_LIVE_DETAIL,
MOCK_LIVE_ANCHOR,
MOCK_LIVE_STREAM,
MOCK_COMMENTS,
MOCK_DANMAKU,
MOCK_SEARCH_RESULTS,
MOCK_HOT_SEARCH,
MOCK_SEARCH_SUGGEST,
MOCK_UPLOADER_INFO,
MOCK_UPLOADER_VIDEOS,
MOCK_USER_INFO,
MOCK_QR_CODE,
PUBLIC_VIDEOS,
} from './mockData';
export async function getRecommendFeed(_freshIdx = 0): Promise<VideoItem[]> {
return MOCK_VIDEOS;
}
export async function getPopularVideos(_pn = 1): Promise<VideoItem[]> {
return MOCK_VIDEOS;
}
export function getVideoDetail(_bvid: string): Promise<VideoItem> {
return Promise.resolve(MOCK_VIDEO_DETAIL);
}
export async function getVideoRelated(_bvid: string): Promise<VideoItem[]> {
return MOCK_VIDEOS.slice(5, 15);
}
export function getPlayUrl(_bvid: string, _cid: number, _qn = 64): Promise<PlayUrlResponse> {
return Promise.resolve(MOCK_PLAY_URL);
}
export async function getPlayUrlForDownload(
_bvid: string,
_cid: number,
_qn = 64,
): Promise<string> {
return PUBLIC_VIDEOS[0].url;
}
export async function getUploaderStat(_mid: number): Promise<{ follower: number; archiveCount: number }> {
return {
follower: MOCK_UPLOADER_INFO.follower,
archiveCount: MOCK_UPLOADER_INFO.archiveCount,
};
}
export async function getUploaderInfo(_mid: number): Promise<{ name: string; face: string; sign: string; follower: number; archiveCount: number }> {
return MOCK_UPLOADER_INFO;
}
export async function getUploaderVideos(_mid: number, pn = 1, ps = 20): Promise<{ videos: VideoItem[]; total: number }> {
const allVideos = MOCK_UPLOADER_VIDEOS.videos;
const start = (pn - 1) * ps;
const videos = allVideos.slice(start, start + ps);
return { videos, total: MOCK_UPLOADER_VIDEOS.total };
}
export async function getUserInfo(): Promise<{ face: string; uname: string; mid: number }> {
return MOCK_USER_INFO;
}
export async function getComments(
_aid: number,
_cursor = '',
_sort = 2,
): Promise<{ replies: Comment[]; nextCursor: string; isEnd: boolean }> {
return {
replies: MOCK_COMMENTS,
nextCursor: '',
isEnd: true,
};
}
export async function getVideoShot(_bvid: string, _cid: number): Promise<VideoShotData | null> {
return null;
}
export async function generateQRCode(): Promise<QRCodeInfo> {
return MOCK_QR_CODE;
}
export async function pollQRCode(_qrcode_key: string): Promise<{ code: number; cookie?: string }> {
return { code: 0, cookie: 'mock-sessdata-2025' };
}
export async function getLiveList(_page = 1, _parentAreaId = 0): Promise<LiveRoom[]> {
return MOCK_LIVE_ROOMS;
}
export async function getLiveRoomDetail(_roomId: number): Promise<LiveRoomDetail> {
return MOCK_LIVE_DETAIL;
}
export async function getLiveAnchorInfo(_roomId: number): Promise<LiveAnchorInfo> {
return MOCK_LIVE_ANCHOR;
}
export async function getLiveStreamUrl(_roomId: number, _qn = 10000): Promise<LiveStreamInfo> {
return MOCK_LIVE_STREAM;
}
export async function searchVideos(_keyword: string, _page = 1, _order = ''): Promise<VideoItem[]> {
return MOCK_SEARCH_RESULTS;
}
export async function getLiveDanmakuHistory(_roomId: number): Promise<{
danmakus: DanmakuItem[];
adminMsgs: string[];
}> {
return {
danmakus: MOCK_DANMAKU.slice(0, 10).map(d => ({ ...d, timeline: '2025-03-26 20:00:00' })),
adminMsgs: ['欢迎来到直播间!', '主播正在直播中,请文明发言'],
};
}
export async function getDanmaku(_cid: number): Promise<DanmakuItem[]> {
return MOCK_DANMAKU;
}
export async function getFollowedLiveRooms(): Promise<LiveRoom[]> {
return MOCK_LIVE_ROOMS.slice(0, 3);
}
export async function getSearchSuggest(_term: string): Promise<SearchSuggestItem[]> {
return MOCK_SEARCH_SUGGEST;
}
export async function getHotSearch(): Promise<HotSearchItem[]> {
return MOCK_HOT_SEARCH;
}

291
services/mockData/index.ts Normal file
View File

@@ -0,0 +1,291 @@
import type {
VideoItem,
Comment,
PlayUrlResponse,
QRCodeInfo,
DanmakuItem,
LiveRoom,
LiveRoomDetail,
LiveAnchorInfo,
LiveStreamInfo,
SearchSuggestItem,
HotSearchItem,
} from '../types';
// ─── Public-domain video URLs (Blender Foundation, CC BY 3.0) ────────────────
const VIDEOS_BASE = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample';
export const PUBLIC_VIDEOS = [
{ url: `${VIDEOS_BASE}/BigBuckBunny.mp4`, duration: 596 },
{ url: `${VIDEOS_BASE}/ElephantsDream.mp4`, duration: 653 },
{ url: `${VIDEOS_BASE}/ForBiggerBlazes.mp4`, duration: 15 },
{ url: `${VIDEOS_BASE}/ForBiggerEscapes.mp4`, duration: 15 },
{ url: `${VIDEOS_BASE}/SubaruOutbackOnStreetAndDirt.mp4`, duration: 60 },
];
// Public HLS live demo stream
export const PUBLIC_HLS_URL = 'https://demo.unified-streaming.com/k8s/live/stable/univision.isml/univision.m3u8';
// ─── Mock Videos ─────────────────────────────────────────────────────────────
const makeVideo = (
id: number,
title: string,
ownerName: string,
view: number,
duration: number,
desc = '',
): VideoItem => ({
bvid: `BV1mock${String(id).padStart(5, '0')}`,
aid: 170000 + id,
title,
pic: `https://picsum.photos/seed/cover${id}/320/180`,
owner: {
mid: 1000 + id,
name: ownerName,
face: `https://picsum.photos/seed/face${id}/80/80`,
},
stat: {
view,
danmaku: Math.floor(view * 0.01),
reply: Math.floor(view * 0.005),
like: Math.floor(view * 0.08),
coin: Math.floor(view * 0.03),
favorite: Math.floor(view * 0.04),
},
duration,
desc: desc || `${title} - 精彩内容等你来看`,
cid: 200000 + id,
pages: [{ cid: 200000 + id, part: 'P1' }],
});
export const MOCK_VIDEOS: VideoItem[] = [
makeVideo(1, 'React Native 开发实战:从零打造视频 App', '前端小课堂', 892341, 3612, 'React Native + Expo 完整项目开发教程'),
makeVideo(2, '2025 最值得买的数码产品 TOP10 盘点', '数码测评君', 1234567, 847),
makeVideo(3, '【旅行 Vlog】独自骑行川藏线全记录', '骑行者小明', 2341890, 5432),
makeVideo(4, '零基础学 TypeScript类型系统完全指南', 'Code时间', 543210, 4821),
makeVideo(5, '家常红烧肉的正确做法,肥而不腻的秘诀', '厨艺达人', 3456789, 614),
makeVideo(6, '【游戏解说】原神:深境螺旋 36 星通关思路', '游戏攻略站', 1876543, 1823),
makeVideo(7, '自制小型无人机:从电路到飞行全过程', '创客工坊', 654321, 2156),
makeVideo(8, '《流浪地球》系列深度解析:科幻背后的科学', '影视解说', 4321098, 1245),
makeVideo(9, '街头摄影技巧:用手机拍出胶片感', '摄影日记', 765432, 731),
makeVideo(10, 'Python 爬虫入门到实战B 站数据分析', '编程达人', 432109, 3024),
makeVideo(11, '健身房新手指南:第一个月该如何训练', '运动博主', 987654, 1132),
makeVideo(12, '极简主义生活 30 天挑战:我的真实体验', '生活方式', 543210, 892),
makeVideo(13, '【科普】量子计算机究竟有多厉害?', '科技前沿', 2109876, 1543),
makeVideo(14, '钢琴自学 3 个月:我的进步汇报', '音乐小站', 432109, 423),
makeVideo(15, '深夜食堂系列:一个人的火锅', '美食治愈系', 1234567, 512),
makeVideo(16, 'Kubernetes 生产环境部署实战', '云原生笔记', 321098, 5421),
makeVideo(17, '二次元周边开箱:这次花了多少钱', '动漫爱好者', 876543, 723),
makeVideo(18, '城市骑行探店:寻找北京最好吃的煎饼', '骑行探城', 654321, 1823),
makeVideo(19, 'After Effects 粒子特效从入门到精通', '视觉创作者', 543210, 4215),
makeVideo(20, '古典园林建筑美学:苏州园林深度游', '人文记录', 765432, 2134),
];
// ─── Mock Video Detail (single item with pages) ───────────────────────────────
export const MOCK_VIDEO_DETAIL: VideoItem = {
...MOCK_VIDEOS[0],
pages: [
{ cid: 200001, part: '第一集:环境搭建与项目初始化' },
{ cid: 200002, part: '第二集:路由系统与页面跳转' },
{ cid: 200003, part: '第三集:数据请求与状态管理' },
],
ugc_season: {
id: 5001,
title: 'React Native 开发系列',
cover: 'https://picsum.photos/seed/season1/320/180',
ep_count: 3,
sections: [{
episodes: [
{ aid: 170001, bvid: 'BV1mock00001', cid: 200001, title: '第一集:环境搭建与项目初始化', arc: { pic: 'https://picsum.photos/seed/ep1/320/180', stat: { view: 120000 } } },
{ aid: 170002, bvid: 'BV1mock00002', cid: 200002, title: '第二集:路由系统与页面跳转', arc: { pic: 'https://picsum.photos/seed/ep2/320/180', stat: { view: 98000 } } },
{ aid: 170003, bvid: 'BV1mock00003', cid: 200003, title: '第三集:数据请求与状态管理', arc: { pic: 'https://picsum.photos/seed/ep3/320/180', stat: { view: 87000 } } },
],
}],
},
};
// ─── Mock Play URL (Big Buck Bunny MP4) ───────────────────────────────────────
export const MOCK_PLAY_URL: PlayUrlResponse = {
quality: 64,
accept_quality: [64, 32, 16],
accept_description: ['720P 高清', '480P 清晰', '360P 流畅'],
durl: [
{
url: PUBLIC_VIDEOS[0].url,
length: PUBLIC_VIDEOS[0].duration * 1000,
size: 158008374,
},
],
};
// ─── Mock Live Rooms ──────────────────────────────────────────────────────────
const makeLiveRoom = (id: number, title: string, uname: string, online: number, area: string, parentArea: string): LiveRoom => ({
roomid: 10000 + id,
uid: 2000 + id,
title,
uname,
face: `https://picsum.photos/seed/lface${id}/80/80`,
cover: `https://picsum.photos/seed/lcover${id}/320/180`,
online,
area_name: area,
parent_area_name: parentArea,
});
export const MOCK_LIVE_ROOMS: LiveRoom[] = [
makeLiveRoom(1, '【王者荣耀】冲击王者段位!今晚必上!', '游戏达人_阿强', 34521, '王者荣耀', '网游'),
makeLiveRoom(2, '手工皮具制作直播 | 今天做一个手包', '皮艺工坊', 8921, '手工', '生活'),
makeLiveRoom(3, '午夜 Lo-Fi 音乐陪你学习/工作', 'ChillBeats', 15432, '聊天', '娱乐'),
makeLiveRoom(4, '【原神】4.4 版本新角色实机测试', '原神攻略组', 56789, '原神', '手游'),
makeLiveRoom(5, '开心农场直播 | 今天种番茄', '农场日记', 3241, '户外', '生活'),
makeLiveRoom(6, '前端开发直播 | React 组件库搭建中', '码农老王', 4567, '编程', '科技'),
makeLiveRoom(7, '街头篮球挑战赛直播', '球场风云', 23456, '篮球', '体育'),
makeLiveRoom(8, '深夜美食 | 自制拉面挑战', '吃货小熊', 12345, '美食', '生活'),
makeLiveRoom(9, '油画创作直播 | 荷花池系列第三幅', '画室时光', 5678, '绘画', '知识'),
makeLiveRoom(10, '电子音乐制作 | 今天做一首 House', 'DJ小飞', 9876, '音乐', '娱乐'),
];
// ─── Mock Live Detail ─────────────────────────────────────────────────────────
export const MOCK_LIVE_DETAIL: LiveRoomDetail = {
roomid: 10001,
uid: 2001,
title: '【王者荣耀】冲击王者段位!今晚必上!',
description: '每晚 8 点开播,带你上分,欢迎关注!',
live_status: 1,
online: 34521,
area_name: '王者荣耀',
parent_area_name: '网游',
keyframe: 'https://picsum.photos/seed/lcover1/320/180',
};
// ─── Mock Live Anchor ─────────────────────────────────────────────────────────
export const MOCK_LIVE_ANCHOR: LiveAnchorInfo = {
uid: 2001,
uname: '游戏达人_阿强',
face: 'https://picsum.photos/seed/lface1/80/80',
};
// ─── Mock Live Stream ─────────────────────────────────────────────────────────
export const MOCK_LIVE_STREAM: LiveStreamInfo = {
hlsUrl: PUBLIC_HLS_URL,
flvUrl: '',
qn: 10000,
qualities: [
{ qn: 10000, desc: '原画' },
{ qn: 400, desc: '蓝光' },
{ qn: 250, desc: '超清' },
{ qn: 150, desc: '高清' },
],
};
// ─── Mock Comments ────────────────────────────────────────────────────────────
const makeComment = (id: number, message: string, uname: string, like: number, ctime: number, replies?: Comment[]): Comment => ({
rpid: id,
content: { message },
member: {
uname,
avatar: `https://picsum.photos/seed/avatar${id}/80/80`,
},
like,
ctime,
replies: replies ?? null,
});
export const MOCK_COMMENTS: Comment[] = [
makeComment(1, '这个教程真的太详细了,跟着做了一遍,全部成功!感谢 UP 主!', '学习中的小白', 3421, 1711900800, [
makeComment(101, '同感!我也跟着做了,一次就跑通了', '一起学习吧', 156, 1711901000),
makeComment(102, '建议 UP 主出续集,期待更多内容', '热心观众', 89, 1711902000),
]),
makeComment(2, '视频质量非常高,讲解清晰,逻辑连贯,希望能持续更新!', '程序员小张', 2156, 1711800000),
makeComment(3, '有一个问题想问:第 15 分钟那里的代码,我运行报错了,能帮看看吗?', '新手求助', 45, 1711700000),
makeComment(4, '三刷了,每次都有新收获。这才是真正有价值的内容!', '资深学习者', 1876, 1711600000),
makeComment(5, '搭配官方文档一起看效果更好UP 主解释了很多文档里没写清楚的地方', '老码农', 987, 1711500000),
makeComment(6, 'UP 主声音好好听,讲解也好清晰,已一键三连', '铁粉', 765, 1711400000),
makeComment(7, '这个项目已经在我们公司用上了,非常实用,谢谢分享!', '公司 CTO', 2345, 1711300000),
makeComment(8, '期待下一期!什么时候更新啊', '催更小队', 432, 1711200000),
makeComment(9, '讲得太好了,我把链接发给了我所有搞技术的朋友', '传播者', 876, 1711100000),
makeComment(10, '从零基础看到现在,终于看懂了!感动到流泪', '逆袭中', 1234, 1711000000),
];
// ─── Mock Danmaku ─────────────────────────────────────────────────────────────
export const MOCK_DANMAKU: DanmakuItem[] = [
{ time: 3.2, mode: 1, fontSize: 25, color: 0xffffff, text: '开始了开始了!' },
{ time: 5.8, mode: 1, fontSize: 25, color: 0x00aeec, text: '这个项目好厉害!' },
{ time: 8.1, mode: 1, fontSize: 25, color: 0xffffff, text: '感谢 UP 主分享' },
{ time: 12.4, mode: 1, fontSize: 25, color: 0xffdd00, text: '前排占座' },
{ time: 15.7, mode: 1, fontSize: 25, color: 0xffffff, text: '这个思路很清晰' },
{ time: 20.3, mode: 1, fontSize: 25, color: 0xff6699, text: '哇真的吗!' },
{ time: 25.6, mode: 1, fontSize: 25, color: 0xffffff, text: '666666' },
{ time: 30.1, mode: 1, fontSize: 25, color: 0x00ff00, text: '学到了学到了' },
{ time: 35.9, mode: 1, fontSize: 25, color: 0xffffff, text: '这里有点没懂,多看几遍' },
{ time: 42.0, mode: 1, fontSize: 25, color: 0xffd700, text: '牛!!!' },
{ time: 48.5, mode: 1, fontSize: 25, color: 0xffffff, text: '已经收藏了' },
{ time: 55.2, mode: 1, fontSize: 25, color: 0xff4500, text: '三连了!' },
{ time: 62.8, mode: 1, fontSize: 25, color: 0xffffff, text: '讲得比我老师好多了' },
{ time: 70.3, mode: 4, fontSize: 25, color: 0x00aeec, text: '精华部分来了' },
{ time: 78.9, mode: 1, fontSize: 25, color: 0xffffff, text: '笔记已做好' },
{ time: 85.4, mode: 1, fontSize: 25, color: 0xff69b4, text: '太棒了' },
{ time: 92.1, mode: 1, fontSize: 25, color: 0xffffff, text: '这部分之前一直不理解' },
{ time: 100.6, mode: 1, fontSize: 25, color: 0xadff2f, text: '醍醐灌顶!' },
{ time: 110.2, mode: 1, fontSize: 25, color: 0xffffff, text: '求下一期!' },
{ time: 120.8, mode: 1, fontSize: 25, color: 0x87ceeb, text: '这才是干货' },
{ time: 130.5, mode: 1, fontSize: 25, color: 0xffffff, text: '做了好久的笔记' },
{ time: 145.3, mode: 1, fontSize: 25, color: 0xff8c00, text: '弹幕飘过' },
{ time: 158.7, mode: 5, fontSize: 25, color: 0xffd700, text: '收藏从未空过' },
{ time: 172.4, mode: 1, fontSize: 25, color: 0xffffff, text: '看了三遍终于懂了' },
{ time: 185.9, mode: 1, fontSize: 25, color: 0x00aeec, text: '这个项目已经部署上线了' },
{ time: 200.2, mode: 1, fontSize: 25, color: 0xffffff, text: '太有用了' },
{ time: 215.6, mode: 1, fontSize: 25, color: 0xff1493, text: '冲!' },
{ time: 230.1, mode: 1, fontSize: 25, color: 0xffffff, text: '发给朋友了' },
{ time: 248.8, mode: 1, fontSize: 25, color: 0x7fffd4, text: '期待续集!' },
{ time: 265.3, mode: 4, fontSize: 25, color: 0xffd700, text: '感谢 UP 主!' },
];
// ─── Mock Search ──────────────────────────────────────────────────────────────
export const MOCK_SEARCH_RESULTS: VideoItem[] = MOCK_VIDEOS.slice(0, 10);
export const MOCK_HOT_SEARCH: HotSearchItem[] = [
{ keyword: 'React Native 教程', show_name: 'React Native 教程' },
{ keyword: '2025 科技趋势', show_name: '2025 科技趋势' },
{ keyword: '原神新角色', show_name: '原神新角色' },
{ keyword: '零基础学编程', show_name: '零基础学编程' },
{ keyword: '街头摄影技巧', show_name: '街头摄影技巧' },
{ keyword: '健身入门指南', show_name: '健身入门指南' },
{ keyword: '美食制作教程', show_name: '美食制作教程' },
{ keyword: '音乐制作软件', show_name: '音乐制作软件' },
{ keyword: '旅行 vlog 拍摄', show_name: '旅行 vlog 拍摄' },
{ keyword: 'TypeScript 最佳实践', show_name: 'TypeScript 最佳实践' },
];
export const MOCK_SEARCH_SUGGEST: SearchSuggestItem[] = [
{ value: 'React Native 开发教程', ref: 1000 },
{ value: 'React Native 环境搭建', ref: 800 },
{ value: 'React Native 路由跳转', ref: 650 },
{ value: 'React Native 性能优化', ref: 500 },
{ value: 'React Native 动画效果', ref: 420 },
];
// ─── Mock Uploader ────────────────────────────────────────────────────────────
export const MOCK_UPLOADER_INFO = {
name: '前端小课堂',
face: 'https://picsum.photos/seed/uploader1/80/80',
sign: '专注前端开发教学,每周更新高质量视频教程。如果内容对你有帮助,欢迎关注!',
follower: 328900,
archiveCount: 156,
};
export const MOCK_UPLOADER_VIDEOS = {
videos: MOCK_VIDEOS.slice(0, 12),
total: 156,
};
// ─── Mock User Info ───────────────────────────────────────────────────────────
export const MOCK_USER_INFO = {
face: 'https://picsum.photos/seed/myavatar/80/80',
uname: '访客用户',
mid: 99999,
};
// ─── Mock QR Code ─────────────────────────────────────────────────────────────
export const MOCK_QR_CODE: QRCodeInfo = {
url: 'https://example.com/mock-login-qr',
qrcode_key: 'mock-qrcode-key-2025',
};

180
services/types.ts Normal file
View File

@@ -0,0 +1,180 @@
export interface VideoItem {
bvid: string;
aid: number;
title: string;
pic: string;
owner: {
mid: number;
name: string;
face: string;
};
stat?: {
view: number;
danmaku: number;
reply: number;
like: number;
coin: number;
favorite: number;
} | null;
duration: number;
desc: string;
cid?: number;
pages?: Array<{ cid: number; part: string }>;
goto?: 'av' | 'live';
roomid?: number;
online?: number;
area_name?: string;
ugc_season?: {
id: number;
title: string;
cover: string;
ep_count: number;
sections: Array<{
episodes: Array<{
aid: number;
bvid: string;
cid: number;
title: string;
arc?: { pic: string; stat?: { view: number } };
}>;
}>;
};
}
export interface Comment {
rpid: number;
content: { message: string };
member: {
uname: string;
avatar: string;
};
like: number;
ctime: number;
replies: Comment[] | null;
}
export interface DashSegmentBase {
initialization: string;
index_range: string;
}
export interface DashVideoItem {
id: number;
baseUrl: string;
bandwidth: number;
mimeType: string;
codecs: string;
width: number;
height: number;
stat:any;
frameRate: string;
segment_base?: DashSegmentBase;
dolby_type?: number; // 1=杜比视界
hdr_type?: number; // 1=HDR
}
export interface DashAudioItem {
id: number;
baseUrl: string;
bandwidth: number;
mimeType: string;
codecs: string;
segment_base?: DashSegmentBase;
}
export interface PlayUrlResponse {
durl?: Array<{
url: string;
length: number;
size: number;
}>;
dash?: {
duration: number;
video: DashVideoItem[];
audio: DashAudioItem[];
};
dolby?: {
type: number; // 1=杜比全景声
audio?: DashAudioItem[];
};
quality: number;
accept_quality: number[];
accept_description: string[];
}
export interface QRCodeInfo {
url: string;
qrcode_key: string;
}
export interface VideoShotData {
img_x_len: number;
img_y_len: number;
img_x_size: number;
img_y_size: number;
image: string[];
index: number[]; // frame index per second: index[t] = frame idx at second t
pvdata?: string;
}
export interface DanmakuItem {
time: number; // 秒float弹幕出现时间
mode: 1 | 4 | 5; // 1=滚动, 4=底部固定, 5=顶部固定
fontSize: number;
color: number; // 0xRRGGBB 十进制整数
text: string;
uname?: string;
isAdmin?: boolean;
guardLevel?: number; // 0=无, 1=总督, 2=提督, 3=舰长
medalLevel?: number;
medalName?: string;
timeline?: string; // "2024-01-01 12:00:00" 直播弹幕时间戳
}
export interface LiveRoom {
roomid: number;
uid: number;
title: string;
uname: string;
face: string;
cover: string;
online: number;
area_name: string;
parent_area_name: string;
}
export interface LiveRoomDetail {
roomid: number;
uid: number;
title: string;
description: string;
live_status: number; // 1=直播中, 0=未开播
online: number;
area_name: string;
parent_area_name: string;
keyframe: string;
}
export interface LiveAnchorInfo {
uid: number;
uname: string;
face: string;
}
export interface LiveStreamInfo {
hlsUrl: string;
flvUrl: string;
qn: number;
qualities: { qn: number; desc: string }[];
}
export interface SearchSuggestItem {
value: string;
ref: number;
}
export interface HotSearchItem {
keyword: string;
show_name: string;
icon?: string;
}

View File

@@ -0,0 +1,7 @@
/** Web shim for expo-clipboard - use native browser clipboard API */
export async function setStringAsync(text: string): Promise<void> {
try { await navigator.clipboard.writeText(text); } catch {}
}
export async function getStringAsync(): Promise<string> {
try { return await navigator.clipboard.readText(); } catch { return ''; }
}

View File

@@ -0,0 +1,14 @@
/** Web shim for expo-file-system */
export const documentDirectory = '';
export const cacheDirectory = '';
export async function getInfoAsync(_uri: string) { return { exists: false, isDirectory: false }; }
export async function readAsStringAsync(_uri: string) { return ''; }
export async function writeAsStringAsync(_uri: string, _contents: string) {}
export async function deleteAsync(_uri: string) {}
export async function moveAsync(_opts: any) {}
export async function copyAsync(_opts: any) {}
export async function makeDirectoryAsync(_uri: string) {}
export async function getContentUriAsync(_uri: string) { return ''; }
export function createDownloadResumable(_url: string, _fileUri: string, _opts?: any, _cb?: any) {
return { downloadAsync: async () => ({}) };
}

View File

@@ -0,0 +1,5 @@
/** Web shim for expo-intent-launcher - no-op for web */
export async function startActivityAsync(_activity: string, _params?: unknown): Promise<unknown> {
return {};
}
export const ActivityAction = {};

10
shims/expo-network.web.ts Normal file
View File

@@ -0,0 +1,10 @@
/** Web shim for expo-network */
export enum NetworkStateType {
NONE = 0, UNKNOWN = 1, CELLULAR = 2, WIFI = 3, BLUETOOTH = 4,
ETHERNET = 5, WIMAX = 6, VPN = 7, OTHER = 8,
}
export async function getNetworkStateAsync() {
return { isConnected: navigator.onLine, isInternetReachable: navigator.onLine, type: NetworkStateType.UNKNOWN };
}
export async function getIpAddressAsync(): Promise<string> { return '0.0.0.0'; }
export async function isAirplaneModeEnabledAsync(): Promise<boolean> { return false; }

View File

@@ -0,0 +1,49 @@
/**
* Web shim for react-native-pager-view.
* Supports setPage/setPageWithoutAnimation via imperative handle.
*/
import React from 'react';
import { View, type ViewStyle } from 'react-native';
interface PagerViewProps {
children: React.ReactNode;
style?: ViewStyle;
initialPage?: number;
scrollEnabled?: boolean;
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
onPageScrollStateChanged?: (e: any) => void;
[key: string]: any;
}
export interface PagerViewHandle {
setPage: (page: number) => void;
setPageWithoutAnimation: (page: number) => void;
}
const PagerView = React.forwardRef<PagerViewHandle, PagerViewProps>(
({ children, style, initialPage = 0, onPageSelected }, ref) => {
const [currentPage, setCurrentPage] = React.useState(initialPage);
const pages = React.Children.toArray(children);
React.useImperativeHandle(ref, () => ({
setPage(page: number) {
setCurrentPage(page);
onPageSelected?.({ nativeEvent: { position: page } });
},
setPageWithoutAnimation(page: number) {
setCurrentPage(page);
onPageSelected?.({ nativeEvent: { position: page } });
},
}));
return (
<View style={[{ flex: 1 }, style]}>
{pages[currentPage] ?? pages[0] ?? null}
</View>
);
},
);
PagerView.displayName = 'PagerView';
export default PagerView;

View File

@@ -0,0 +1,8 @@
/** Web shim for @dr.pogodin/react-native-static-server - no-op for web */
export default class StaticServer {
constructor(_port?: number, _root?: string, _options?: unknown) {}
start(): Promise<string> { return Promise.resolve(''); }
stop(): Promise<void> { return Promise.resolve(); }
isRunning(): boolean { return false; }
get origin(): string { return ''; }
}

View File

@@ -0,0 +1,42 @@
/** Web shim for react-native-video - uses HTML5 <video> element */
import React from 'react';
interface VideoProps {
source?: { uri?: string } | number;
style?: React.CSSProperties;
paused?: boolean;
muted?: boolean;
repeat?: boolean;
onLoad?: (data: unknown) => void;
onError?: (error: unknown) => void;
onProgress?: (data: unknown) => void;
onEnd?: () => void;
resizeMode?: string;
[key: string]: unknown;
}
const Video = React.forwardRef<HTMLVideoElement, VideoProps>(
({ source, style, paused, muted, repeat, onLoad, onError, onProgress, onEnd }, ref) => {
const uri = typeof source === 'object' && source !== null ? (source as { uri?: string }).uri : undefined;
return (
<video
ref={ref}
src={uri}
style={style as React.CSSProperties}
autoPlay={!paused}
muted={muted}
loop={repeat}
onLoadedData={onLoad ? () => onLoad({}) : undefined}
onError={onError ? (e) => onError(e) : undefined}
onTimeUpdate={onProgress ? (e) => {
const t = e.currentTarget;
onProgress({ currentTime: t.currentTime, seekableDuration: t.duration });
} : undefined}
onEnded={onEnd}
playsInline
/>
);
}
);
export default Video;

View File

@@ -0,0 +1,30 @@
/** Web shim for @sentry/react-native - no-op stubs for web platform */
import React from 'react';
export const init = (_options?: unknown) => {};
export const wrap = <T>(component: T): T => component;
export const captureException = (_error: unknown, _hint?: unknown) => '';
export const captureMessage = (_message: string, _level?: unknown) => '';
export const setUser = (_user: unknown) => {};
export const setTag = (_key: string, _value: unknown) => {};
export const setExtra = (_key: string, _extra: unknown) => {};
export const addBreadcrumb = (_breadcrumb: unknown) => {};
export const configureScope = (_callback: unknown) => {};
export const withScope = (_callback: unknown) => {};
export const getCurrentHub = () => ({ getClient: () => undefined });
export const ReactNativeTracing = class {};
export const ReactNavigationInstrumentation = class {};
export const TouchEventBoundary = ({ children }: { children: React.ReactNode }) => children;
export class ErrorBoundary extends React.Component<
{ fallback: React.ReactNode; children: React.ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
export default { init, wrap, captureException, captureMessage, setUser, setTag, setExtra };

68
store/authStore.ts Normal file
View File

@@ -0,0 +1,68 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getUserInfo } from '../services/api';
import { getSecure, setSecure, deleteSecure } from '../utils/secureStorage';
interface AuthState {
sessdata: string | null;
uid: string | null;
username: string | null;
face: string | null;
isLoggedIn: boolean;
login: (sessdata: string, uid: string, username?: string) => Promise<void>;
logout: () => Promise<void>;
restore: () => Promise<void>;
setProfile: (face: string, username: string, uid: string) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
sessdata: null,
uid: null,
username: null,
face: null,
isLoggedIn: false,
login: async (sessdata, uid, username) => {
await setSecure('SESSDATA', sessdata);
// Migrate: remove SESSDATA from AsyncStorage if it was there before
await AsyncStorage.removeItem('SESSDATA').catch(() => { });
set({ sessdata, uid: uid || null, username: username || null, isLoggedIn: true });
getUserInfo().then(async (info) => {
await AsyncStorage.multiSet([
['UID', String(info.mid)],
['USERNAME', info.uname],
['FACE', info.face],
]).catch(() => { });
set({ face: info.face, username: info.uname, uid: String(info.mid) });
}).catch(() => { });
},
logout: async () => {
await deleteSecure('SESSDATA');
await AsyncStorage.multiRemove(['UID', 'USERNAME', 'FACE']);
set({ sessdata: null, uid: null, username: null, face: null, isLoggedIn: false });
},
restore: async () => {
// Try SecureStore first, fallback to AsyncStorage for migration
let sessdata = await getSecure('SESSDATA');
if (!sessdata) {
sessdata = await AsyncStorage.getItem('SESSDATA');
if (sessdata) {
// Migrate from AsyncStorage to SecureStore
await setSecure('SESSDATA', sessdata);
await AsyncStorage.removeItem('SESSDATA');
}
}
if (sessdata) {
set({ sessdata, isLoggedIn: true });
try {
const info = await getUserInfo();
await AsyncStorage.setItem('FACE', info.face);
set({ face: info.face, username: info.uname, uid: String(info.mid) });
} catch { }
}
},
setProfile: (face, username, uid) => set({ face, username, uid }),
}));

96
store/downloadStore.ts Normal file
View File

@@ -0,0 +1,96 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface DownloadTask {
bvid: string;
title: string;
cover: string;
qn: number;
qdesc: string;
status: 'downloading' | 'done' | 'error';
progress: number; // 0-1
fileSize?: number; // bytes
localUri?: string;
error?: string;
createdAt: number;
}
interface DownloadStore {
tasks: Record<string, DownloadTask>; // key: `${bvid}_${qn}`
hasLoaded: boolean;
addTask: (key: string, task: DownloadTask) => void;
updateTask: (key: string, patch: Partial<DownloadTask>) => void;
removeTask: (key: string) => void;
loadFromStorage: () => Promise<void>;
}
const STORAGE_KEY = 'download_tasks';
export const useDownloadStore = create<DownloadStore>((set, get) => ({
tasks: {},
hasLoaded: false,
addTask: (key, task) => {
const next = { ...get().tasks, [key]: task };
set({ tasks: next });
persistTasks(next);
},
updateTask: (key, patch) => {
set((s) => {
const prev = s.tasks[key];
if (!prev) return s;
const updated = { ...prev, ...patch };
const next = { ...s.tasks, [key]: updated };
// 只在状态/localUri变化时持久化progress 不持久化(避免高频写 AsyncStorage
if (patch.status !== undefined || patch.localUri !== undefined || patch.error !== undefined || patch.fileSize !== undefined) {
persistTasks(next);
}
return { tasks: next };
});
},
removeTask: (key) => {
set((s) => {
const next = { ...s.tasks };
delete next[key];
persistTasks(next);
return { tasks: next };
});
},
loadFromStorage: async () => {
// 已加载过则跳过,防止重复调用覆盖正在进行的下载状态
if (get().hasLoaded) return;
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const saved: Record<string, DownloadTask> = JSON.parse(raw);
// 重启后 downloading 任务无法续传,标记为 error
Object.keys(saved).forEach((k) => {
if (saved[k].status === 'downloading') {
saved[k].status = 'error';
saved[k].error = '下载被中断,请重试';
saved[k].progress = 0;
}
});
set({ tasks: saved, hasLoaded: true });
} else {
set({ hasLoaded: true });
}
} catch {
set({ hasLoaded: true });
}
},
}));
function persistTasks(tasks: Record<string, DownloadTask>) {
// 只持久化 done/errordownloading 重启后无法续传无需保存
const toSave: Record<string, DownloadTask> = {};
Object.keys(tasks).forEach((k) => {
if (tasks[k].status !== 'downloading') {
toSave[k] = tasks[k];
}
});
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)).catch(() => {});
}

23
store/liveStore.ts Normal file
View File

@@ -0,0 +1,23 @@
import { create } from 'zustand';
interface LiveStore {
isActive: boolean;
roomId: number;
title: string;
cover: string; // room.keyframe直播截图用于 web 端降级封面
hlsUrl: string;
setLive: (roomId: number, title: string, cover: string, hlsUrl: string) => void;
clearLive: () => void;
}
export const useLiveStore = create<LiveStore>(set => ({
isActive: false,
roomId: 0,
title: '',
cover: '',
hlsUrl: '',
setLive: (roomId, title, cover, hlsUrl) =>
set({ isActive: true, roomId, title, cover, hlsUrl }),
clearLive: () =>
set({ isActive: false, roomId: 0, title: '', cover: '', hlsUrl: '' }),
}));

36
store/settingsStore.ts Normal file
View File

@@ -0,0 +1,36 @@
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface SettingsState {
darkMode: boolean;
trafficSaving: boolean;
setDarkMode: (v: boolean) => Promise<void>;
setTrafficSaving: (v: boolean) => Promise<void>;
restore: () => Promise<void>;
}
export const useSettingsStore = create<SettingsState>((set) => ({
darkMode: false,
trafficSaving: false,
setDarkMode: async (v) => {
await AsyncStorage.setItem('DARK_MODE', v ? '1' : '0');
set({ darkMode: v });
},
setTrafficSaving: async (v) => {
await AsyncStorage.setItem('TRAFFIC_SAVING', v ? '1' : '0');
set({ trafficSaving: v });
},
restore: async () => {
const [dm, ts] = await Promise.all([
AsyncStorage.getItem('DARK_MODE'),
AsyncStorage.getItem('TRAFFIC_SAVING'),
]);
set({
darkMode: dm === '1',
trafficSaving: ts === '1',
});
},
}));

19
store/videoStore.ts Normal file
View File

@@ -0,0 +1,19 @@
import { create } from 'zustand';
interface VideoStore {
isActive: boolean;
bvid: string;
title: string;
cover: string;
setVideo: (bvid: string, title: string, cover: string) => void;
clearVideo: () => void;
}
export const useVideoStore = create<VideoStore>(set => ({
isActive: false,
bvid: '',
title: '',
cover: '',
setVideo: (bvid, title, cover) => set({ isActive: true, bvid, title, cover }),
clearVideo: () => set({ isActive: false }),
}));

10
test-cmd.ps1 Normal file
View File

@@ -0,0 +1,10 @@
$env:PATH = "C:\nvm4w\nodejs;" + $env:PATH
subst Z: "C:\Users\Administrator\Desktop\claude code studly\reactJKVideoApp" 2>$null
Start-Sleep -Milliseconds 500
Write-Host "=== Testing autolinking from Z:\android ===" -ForegroundColor Yellow
$result = & cmd /c "cd /d Z:\android && node --no-warnings --eval `"require('expo/bin/autolinking')`" expo-modules-autolinking resolve --platform android --json 2>&1"
Write-Host "Exit code: $LASTEXITCODE"
$result | Select-Object -First 10 | Write-Host
subst Z: /D 2>$null

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

27
utils/buildMpd.ts Normal file
View File

@@ -0,0 +1,27 @@
// 构建 DASH MPD 文件内容
export function buildMpd(
videoUrl: string,
videoCodecs: string,
videoBandwidth: number,
audioUrl: string,
audioCodecs: string,
audioBandwidth: number,
): string {
return `<?xml version="1.0"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" type="static" mediaPresentationDuration="PT9999S">
<Period>
<AdaptationSet contentType="video" mimeType="video/mp4" segmentAlignment="true">
<Representation id="v1" codecs="${videoCodecs}" bandwidth="${videoBandwidth}">
<BaseURL>${videoUrl}</BaseURL>
<SegmentBase><Initialization range="0-999"/></SegmentBase>
</Representation>
</AdaptationSet>
<AdaptationSet contentType="audio" mimeType="audio/mp4">
<Representation id="a1" codecs="${audioCodecs}" bandwidth="${audioBandwidth}">
<BaseURL>${audioUrl}</BaseURL>
<SegmentBase><Initialization range="0-999"/></SegmentBase>
</Representation>
</AdaptationSet>
</Period>
</MPD>`;
}

63
utils/cache.ts Normal file
View File

@@ -0,0 +1,63 @@
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
/** Get total size (bytes) of a directory recursively */
async function dirSize(dirUri: string): Promise<number> {
try {
const info = await FileSystem.getInfoAsync(dirUri);
if (!info.exists) return 0;
const entries = await FileSystem.readDirectoryAsync(dirUri);
let total = 0;
for (const entry of entries) {
const entryUri = dirUri.endsWith('/') ? `${dirUri}${entry}` : `${dirUri}/${entry}`;
const entryInfo = await FileSystem.getInfoAsync(entryUri, { size: true });
if (!entryInfo.exists) continue;
if (entryInfo.isDirectory) {
total += await dirSize(entryUri);
} else {
total += (entryInfo as any).size ?? 0;
}
}
return total;
} catch {
return 0;
}
}
/** Format bytes to human-readable string */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
/** Calculate image cache size (expo-image uses its own cache dirs) */
export async function getImageCacheSize(): Promise<number> {
if (Platform.OS === 'web') return 0;
const cacheDir = FileSystem.cacheDirectory ?? '';
return dirSize(cacheDir);
}
/** Clear expo-image disk cache + expo-file-system cache directory */
export async function clearImageCache(): Promise<void> {
if (Platform.OS === 'web') return;
try {
const { Image } = require('expo-image');
await Image.clearDiskCache();
await Image.clearMemoryCache();
} catch {}
// Also clear the general cache directory (MPD files, QR codes, etc.)
try {
const cacheDir = FileSystem.cacheDirectory ?? '';
const entries = await FileSystem.readDirectoryAsync(cacheDir);
await Promise.all(
entries.map(async entry => {
const uri = `${cacheDir}${entry}`;
try {
await FileSystem.deleteAsync(uri, { idempotent: true });
} catch {}
})
);
} catch {}
}

26
utils/danmaku.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { DanmakuItem } from '../services/types';
export function parseDanmakuXml(xml: string): DanmakuItem[] {
const re = /<d p="([^"]+)">([^<]*)<\/d>/g;
const items: DanmakuItem[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(xml)) !== null) {
const p = m[1].split(',');
if (p.length < 4) continue;
const time = parseFloat(p[0]);
const mode = parseInt(p[1], 10);
const text = m[2]
.replace(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/g, '"').trim();
if (!text || isNaN(time)) continue;
if (mode !== 1 && mode !== 4 && mode !== 5) continue;
items.push({ time, mode: mode as 1|4|5, fontSize: parseInt(p[2],10), color: parseInt(p[3],10), text });
}
return items.sort((a, b) => a.time - b.time);
}
export function danmakuColorToCss(color: number): string {
return '#' + (color >>> 0 & 0xFFFFFF).toString(16).padStart(6, '0');
}

Some files were not shown because too many files have changed in this diff Show More